Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 4 additions & 4 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion plugin-system/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"immer": "^10.1.1",
"react-hook-form": "^7.46.1",
"use-query-params": "^2.2.1",
"zod": "^3.25.76"
"zod": "^3.25.76",
"semver": "^7.8.0"
},
"peerDependencies": {
"@emotion/react": "^11.14.0",
Expand Down
67 changes: 54 additions & 13 deletions plugin-system/src/components/PluginRegistry/PluginRegistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import {
} from '../../model';
import { PluginRegistryContext } from '../../runtime';
import { useEvent } from '../../utils';
import { usePluginIndexes, getTypeAndKindKey } from './plugin-indexes';
import { usePluginIndexes, PluginCompoundKey } from './plugin-indexes';
import { lookUpDefaultPluginKey } from './getPluginSearchHelper';

export interface PluginRegistryProps {
pluginLoader: PluginLoader;
Expand Down Expand Up @@ -62,28 +63,68 @@ 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;

// 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) {
/**
* By default both version and registry are undefined,
* If one or both are passed, the registry will check if the plugin with the specific version and registry is available,
* falling back to the current behavior which is returning the default.
*/

if (registry || version) {
let compoundKey = '';
/**
* This branch tries to look up a resource deterministically, using a compound_key which consists of kind, name, registry, and version
* Based on the user input, the likely keys are
* 1- kind:name:registry:version (This is the complete compound key. It is very likely the key is looked up)
* 2- kind:name::version (This is very likely, because registry is an optional field of the resource object)
* 3- kind:name:registry: (It is impossible to find any combination, because version is a mandatory field of the resource. So, this will be handled by the fallback)
* Note: It is likely that the key is not found. However, the search should NOT give up easily!
* Instead it should continue with the fallback mechanism
*/
compoundKey = `${kind}:${name}:${registry}:${version}`;
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 should reuse the getPluginModuleCompoundKey otherwise some strings with undefined will be generated.

resource = pluginIndexes.pluginResourcesByNameKindRegistryVersion.get(compoundKey);
if (resource) {
pluginModule = (await loadPluginModule(resource)) as Record<string, Plugin<UnknownSpec>>;
const plugin = pluginModule?.[`${name}:${registry ?? ''}:${version ?? ''}`];
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 we should also have a function for this name, it might seem trivial but is repeated a couple of times.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Its the compound key. We can use the same function no?

if (!plugin) {
throw new Error(
`The ${name} plugin for kind '${kind}' is missing from the ${resource.metadata.name} plugin module`
);
}
return plugin as PluginImplementation<T>;
}
}
/**
* This is the fallback mechanism branch.
* It performs a minimal search using the mandatory inputs from user and returns the default resource
* More information can be found in the searchHelper.ts
*/
const resourceKey = lookUpDefaultPluginKey(
Array.from(pluginIndexes.pluginResourcesByNameKindRegistryVersion.keys()),
{
kind,
name,
}
);

if (!resourceKey) {
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 = pluginIndexes.pluginResourcesByNameKindRegistryVersion.get(resourceKey)!;
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?.[resourceKey];
if (!plugin) {
throw new Error(
`The ${name} plugin for kind '${kind}' is missing from the ${resource.metadata.name} plugin module`
);
}

return plugin as PluginImplementation<T>;
},
[getPluginIndexes, loadPluginModule]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 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 { lookUpDefaultPluginKey } from './getPluginSearchHelper';

describe('getPluginSearchHelper', () => {
describe('same registers but different versions', () => {
const keys: string[] = ['Panel:TimeSeriesChart:dev:1.0.0', 'Panel:TimeSeriesChart:dev:2.0.0'];
it('should take the higher version', () => {
expect(lookUpDefaultPluginKey(keys, { kind: 'Panel', name: 'TimeSeriesChart' })).toBe(
'Panel:TimeSeriesChart:dev:2.0.0'
);
});
});

describe('with and without registry, versions race', () => {
const keys: string[] = [
'Panel:TimeSeriesChart:dev:2.0.0',
'Panel:TimeSeriesChart::1.0.0',
'Panel:TimeSeriesChartX:dev:1.0.0',
'Panel:TimeSeriesChartX::2.0.0',
];

it('should take the higher version', () => {
expect(lookUpDefaultPluginKey(keys, { kind: 'Panel', name: 'TimeSeriesChart' })).toBe(
'Panel:TimeSeriesChart:dev:2.0.0'
);

expect(lookUpDefaultPluginKey(keys, { kind: 'Panel', name: 'TimeSeriesChartX' })).toBe(
'Panel:TimeSeriesChartX::2.0.0'
);
});
});

describe('with and without registry - same versions - check policy', () => {
const keys: string[] = ['Panel:TimeSeriesChart:dev:2.0.0', 'Panel:TimeSeriesChart::2.0.0'];
it('should return the one without the registry by default', () => {
expect(lookUpDefaultPluginKey(keys, { kind: 'Panel', name: 'TimeSeriesChart' })).toBe(
'Panel:TimeSeriesChart::2.0.0'
);
});

it('should return the one with the registry', () => {
expect(
lookUpDefaultPluginKey(keys, { kind: 'Panel', name: 'TimeSeriesChart' }, { registryOverVersion: true })
).toBe('Panel:TimeSeriesChart:dev:2.0.0');
});
});
});
108 changes: 108 additions & 0 deletions plugin-system/src/components/PluginRegistry/getPluginSearchHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// 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 { gt } from 'semver';
import { PluginType } from '../../model';
import { PluginCompoundKey } from './plugin-indexes';

/**
* ____ LOOK UP DEFAULT PLUGIN KEYS WITH PLUGIN TYPE (KIND) AND NAME ___
* This is the fallback mechanism to look up the default plugin using the plugin type (kind) and the name
* When the version and registry are not available, the function shortlists all plugins which have the kind and name combination
* If multiple plugins are nominated, the function will follow a precedence policy
* ___ PLUGIN LOOKUP PRECEDENCE POLICY ___
* The search finds the latest versions available for plugins with and without registry by keeping them in two separate buckets
* 1- If nothing found, simply return undefined
* 2- If only one of the buckets have value, there will be no comparison. Return the one with the value
* 3- If both have the value, check the Precedence Logic input and act accordingly
* 3.1- If the one WITH the registry has a greater version, return it.
* 3.2- If the one WITHOUT the registry has a greater version, return it
* 3.2.1- If we have a draw consider the policy flag
*/

export interface PluginLookupPrecedenceLogic {
registryOverVersion: boolean;
}

const PLUGIN_LOOKUP_PRECEDENCE_LOGIC: PluginLookupPrecedenceLogic = { registryOverVersion: false };

export const lookUpDefaultPluginKey = <T extends PluginType>(
pluginModuleResourceMapKeys: string[],
query: Pick<PluginCompoundKey<T>, 'kind' | 'name'>,
precedenceLogic: PluginLookupPrecedenceLogic = PLUGIN_LOOKUP_PRECEDENCE_LOGIC
): string | undefined => {
type PluginBucket = { key: string; version: string };
const latestFoundVersionWithRegistry: PluginBucket = { key: '', version: '' };
const latestFoundVersionWithoutRegistry: PluginBucket = { key: '', version: '' };

for (const np of pluginModuleResourceMapKeys) {
if (!np.startsWith(`${query.kind}:${query.name}:`)) continue;
const split = np.split(':');

/**
* This is not a valid key. A valid key has 4 sections. The registry is optional. It might be empty or holds a value
*/
if (split.length !== 4) {
console.warn(`An invalid Plugin Resource key detected during default plugin lookup: ${np}`);
continue;
}

const [kind, name, registry, version] = split;
/**
* Such a case is representing a wrong key and it is not technically possible (Just to be precautious)
* A resource MUST have kind, name, and version according to its definition and interface
* So skip this record
*/
if (!kind || !name || !version) {
console.warn(`An invalid Plugin Resource key detected during default plugin lookup: ${np}`);
continue;
}

if (registry) {
if (!latestFoundVersionWithRegistry.key || gt(version, latestFoundVersionWithRegistry.version!)) {
latestFoundVersionWithRegistry.key = np;
latestFoundVersionWithRegistry.version = version;
}
continue;
}

if (!latestFoundVersionWithoutRegistry.key || gt(version, latestFoundVersionWithoutRegistry.version!)) {
latestFoundVersionWithoutRegistry.key = np;
latestFoundVersionWithoutRegistry.version = version;
}
}

/* Before it proceeds with the precedence logic, it checks whether so far anything has been found, if not return undefined */
if ([latestFoundVersionWithRegistry, latestFoundVersionWithoutRegistry].every((p) => !p.key)) {
return undefined;
}

if (latestFoundVersionWithRegistry.key && latestFoundVersionWithoutRegistry.key) {
const { registryOverVersion } = precedenceLogic;

// Registry version is strictly higher
if (gt(latestFoundVersionWithRegistry.version, latestFoundVersionWithoutRegistry.version)) {
return latestFoundVersionWithRegistry.key;
}

// None-registry version is strictly higher
if (gt(latestFoundVersionWithoutRegistry.version, latestFoundVersionWithRegistry.version)) {
return latestFoundVersionWithoutRegistry.key;
}

// Versions are equal - use the tie-breaker
return registryOverVersion ? latestFoundVersionWithRegistry.key : latestFoundVersionWithoutRegistry.key;
}

return latestFoundVersionWithRegistry.key || latestFoundVersionWithoutRegistry?.key;
};
36 changes: 24 additions & 12 deletions plugin-system/src/components/PluginRegistry/plugin-indexes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { PluginLoader, PluginMetadataWithModule, PluginModuleResource, PluginTyp
import { useEvent } from '../../utils';

export interface PluginIndexes {
// Plugin resources by plugin type and kind (i.e. look up what module a plugin type and kind is in)
pluginResourcesByNameAndKind: Map<string, PluginModuleResource>;
// Plugin resources by plugin type, kind, registry, and version
pluginResourcesByNameKindRegistryVersion: Map<string, PluginModuleResource>;
// Plugin metadata by plugin type
pluginMetadataByKind: Map<PluginType, PluginMetadataWithModule[]>;
}
Expand All @@ -34,22 +34,26 @@ export function usePluginIndexes(
const installedPlugins = await getInstalledPlugins();

// Create the two indexes from the installed plugins
const pluginResourcesByNameAndKind = new Map<string, PluginModuleResource>();
const pluginResourcesByNameKindRegistryVersion = new Map<string, PluginModuleResource>();
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);
if (pluginResourcesByNameAndKind.has(key)) {
console.warn(`Got more than one ${kind} plugin for kind ${name}`);
const key = getPluginModuleCompoundKey({ kind, name, registry, version });
if (pluginResourcesByNameKindRegistryVersion.has(key)) {
console.warn(
`Got more than one ${kind} plugin for kind ${name}, registry '${registry || 'undefined'}', and version '${version || 'undefined'}'`
);
}
pluginResourcesByNameAndKind.set(key, resource);
pluginResourcesByNameKindRegistryVersion.set(key, resource);

// Index the metadata by plugin type
let list = pluginMetadataByKind.get(kind);
Expand All @@ -62,7 +66,7 @@ export function usePluginIndexes(
}

return {
pluginResourcesByNameAndKind,
pluginResourcesByNameKindRegistryVersion,
pluginMetadataByKind,
};
});
Expand All @@ -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
Loading
Loading