/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { RouteRecordRaw } from 'vue-router'; import { DSL as STORE_DSL } from '@shell/store/type-map'; import { _DETAIL } from '@shell/config/query-params'; import { CoreStoreInit, Action, Tab, Card, Panel, TableColumn, IPlugin, LocationConfig, ExtensionPoint, TabLocation, ModelExtensionConstructor, PluginRouteRecordRaw, RegisterStore, UnregisterStore, CoreStoreSpecifics, CoreStoreConfig, NavHooks, OnNavToPackage, OnNavAwayFromPackage, OnLogIn, OnLogOut, PaginationTableColumn, ExtensionEnvironment, ServerSidePaginationExtensionConfig } from './types'; import coreStore, { coreStoreModule, coreStoreState } from '@shell/plugins/dashboard-store'; import { defineAsyncComponent, markRaw, Component } from 'vue'; import { getVersionData, CURRENT_RANCHER_VERSION } from '@shell/config/version'; import { ExtensionManagerTypes } from '@shell/types/extension-manager'; /** Registration IDs used for different extension points in the extensions catalog */ export const EXT_IDS = { MODELS: 'models', MODEL_EXTENSION: 'model-extension', /** * Extension can provide resources that use server-side-pagination */ SERVER_SIDE_PAGINATION_RESOURCES: 'server-side-pagination', } as const; export type EXT_IDS_VALUES = (typeof EXT_IDS)[keyof typeof EXT_IDS]; export type ProductFunction = (plugin: IPlugin, store: any) => void; export class Plugin implements IPlugin { public id: string; public name: string; public types: ExtensionManagerTypes = {}; public l10n: { [key: string]: Function[] } = {}; public modelExtensions: { [key: string]: Function[] } = {}; public locales: { locale: string, label: string}[] = []; public products: ProductFunction[] = []; public productNames: string[] = []; public routes: { parent?: string, route: RouteRecordRaw }[] = []; public stores: { storeName: string, register: RegisterStore, unregister: UnregisterStore }[] = []; public onEnter: OnNavToPackage = () => Promise.resolve(); public onLeave: OnNavAwayFromPackage = () => Promise.resolve(); public _onLogOut: OnLogOut = () => Promise.resolve(); public onLogIn: OnLogIn = () => Promise.resolve(); public uiConfig: { [key: string]: any } = {}; // Plugin metadata (plugin package.json) public _metadata: any = {}; public _validators: {[key:string]: Function } = {} // Is this a built-in plugin (bundled with the application) public builtin = false; // Uninstall hooks public uninstallHooks: Function[] = []; constructor(id: string) { this.id = id; this.name = id; // Initialize uiConfig for all of the possible enum values Object.values(ExtensionPoint).forEach((v) => { this.uiConfig[v] = {}; }); } get environment(): ExtensionEnvironment { const versionData = getVersionData(); return { version: versionData.Version, commit: versionData.GitCommit, isPrime: versionData.RancherPrime === 'true', docsVersion: `v${ CURRENT_RANCHER_VERSION }` }; } get metadata() { return this._metadata; } set metadata(value) { this._metadata = value; this.name = this._metadata.name || this.id; } get version() { return this._metadata.version; } get validators() { return this._validators; } set validators(vals: {[key:string]: Function }) { this._validators = vals; } // Track which products the plugin creates DSL(store: any, productName: string) { const storeDSL = STORE_DSL(store, productName); this.productNames.push(productName); return storeDSL; } addProduct(product: ProductFunction): void { this.products.push(product); } addLocale(locale: string, label: string): void { this.locales.push({ locale, label }); } addL10n(locale: string, fn: Function) { this.register('l10n', locale, fn); } addRoutes(routes: PluginRouteRecordRaw[] | RouteRecordRaw[]) { routes.forEach((r: PluginRouteRecordRaw | RouteRecordRaw) => { if (Object.keys(r).includes('parent')) { const pConfig = r as PluginRouteRecordRaw; if (pConfig.parent) { this.addRoute(pConfig.parent, pConfig.route); } else { this.addRoute(pConfig.route); } } else { this.addRoute(r as RouteRecordRaw); } }); } addRoute(parentOrRoute: RouteRecordRaw | string, optionalRoute?: RouteRecordRaw): void { // Always add the pkg name to the route metadata const hasParent = typeof (parentOrRoute) === 'string'; const parent: string | undefined = hasParent ? parentOrRoute as string : undefined; const route: RouteRecordRaw = hasParent ? optionalRoute as RouteRecordRaw : parentOrRoute as RouteRecordRaw; let parentOverride; if (!parent) { // TODO: Inspecting the route object in the browser clearly indicates it's not a RouteRecordRaw. The type needs to be changed or at least extended. const typelessRoute: any = route; if (typelessRoute.component?.layout) { console.warn(`Layouts have been deprecated. We still have parent routes which use the same name and styling as the previous layouts. \n\nFound a component ${ typelessRoute.component.name } with the '${ typelessRoute.component.layout }' layout specified `); // eslint-disable-line no-console parentOverride = typelessRoute.component.layout.toLowerCase(); } else { console.warn(`Layouts have been deprecated. We still have parent routes which use the same name and styling as the previous layouts. You should specify a parent, we're currently setting the parent to 'default'`); // eslint-disable-line no-console parentOverride = 'default'; } } route.meta = { ...route?.meta, pkg: this.name, }; this.routes.push({ parent: parentOverride || parent, route }); } private _addUIConfig(type: string, where: string, when: LocationConfig | string, config: any) { // For convenience 'when' can be a string to indicate a resource, so convert it to the LocationConfig format const locationConfig = (typeof when === 'string') ? { resource: when } : when; this.uiConfig[type][where] = this.uiConfig[type][where] || []; this.uiConfig[type][where].push({ ...config, locationConfig }); } /** * Adds an action/button to the UI */ addAction(where: string, when: LocationConfig | string, action: Action): void { this._addUIConfig(ExtensionPoint.ACTION, where, when, action); } /** * Adds a tab to the UI */ addTab(where: string, when: LocationConfig | string, tab: Tab): void { // tackling https://github.com/rancher/dashboard/issues/11122, we don't want the tab to added in _EDIT view, unless overriden // on extensions side we won't document the mode param for this extension point if (where === TabLocation.RESOURCE_DETAIL && (typeof when === 'object' && !when.mode)) { when.mode = [_DETAIL]; } this._addUIConfig(ExtensionPoint.TAB, where, when, this._createAsyncComponent(tab)); } /** * Adds a panel/component to the UI */ addPanel(where: string, when: LocationConfig | string, panel: Panel): void { this._addUIConfig(ExtensionPoint.PANEL, where, when, this._createAsyncComponent(panel)); } /** * Adds a card to the to the UI */ addCard( where: string, when: LocationConfig | string, card: Card): void { this._addUIConfig(ExtensionPoint.CARD, where, when, this._createAsyncComponent(card)); } /** * Adds a model extension * @experimental May change or be removed in the future * * @param type Model type * @param clz Class for the model extension (constructor) */ addModelExtension(type: string, clz: ModelExtensionConstructor): void { this.register(EXT_IDS.MODEL_EXTENSION, type, clz); } /** * Wraps a component from an extensionConfig with defineAsyncComponent and * markRaw. This prepares the component to be loaded dynamically and prevents * Vue from making the component reactive. * * @param extensionConfig The extension configuration containing a component * to render. * @returns A new object with the same properties as the extension * configuration, but with the component property wrapped in * defineAsyncComponent and markRaw. If the extension configuration doesn't * have a component property, it returns the extension configuration * unchanged. */ private _createAsyncComponent(extensionConfig: Card | Panel | Tab) { const { component } = extensionConfig; if (!component) { return extensionConfig; } return { ...extensionConfig, component: markRaw(defineAsyncComponent(component as () => Promise)), }; } /** * Adds a new column to a ResourceTable * * @param where * @param when * @param action * @param column * The information required to show a header and values for a column in a table * @param paginationColumn * As per `column`, but is used where server-side pagination is enabled */ addTableColumn(where: string, when: LocationConfig | string, column: TableColumn, paginationColumn?: PaginationTableColumn): void { this._addUIConfig(ExtensionPoint.TABLE_COL, where, when, { column, paginationColumn }); } setHomePage(component: any) { this.addRoute({ name: 'home', path: '/home', component }); } addUninstallHook(hook: Function) { this.uninstallHooks.push(hook); } addStore(storeName: string, register: RegisterStore, unregister: UnregisterStore) { this.stores.push({ storeName, register, unregister }); } addDashboardStore(storeName: string, storeSpecifics: CoreStoreSpecifics, config: CoreStoreConfig, init?: CoreStoreInit) { this.stores.push({ storeName, register: () => { return coreStore( this.storeFactory(storeSpecifics, config), config, init, ); }, unregister: (store: any) => { store.unregisterModule(storeName); } }); } private storeFactory(storeSpecifics: CoreStoreSpecifics, config: CoreStoreConfig) { return { ...coreStoreModule, state() { return { ...coreStoreState(config.namespace, config.baseUrl, config.isClusterStore), ...storeSpecifics.state() }; }, getters: { ...coreStoreModule.getters, ...storeSpecifics.getters }, mutations: { ...coreStoreModule.mutations, ...storeSpecifics.mutations }, actions: { ...coreStoreModule.actions, ...storeSpecifics.actions }, }; } public addNavHooks( onEnter?: OnNavToPackage | NavHooks, onLeave?: OnNavAwayFromPackage, onLogOut?: OnLogOut, onLogIn?: OnLogIn, ): void { if (typeof onEnter === 'object') { const hooks = onEnter as NavHooks; if (hooks.onEnter) { this.onEnter = hooks.onEnter; } if (hooks.onLeave) { this.onLeave = hooks.onLeave; } if (hooks.onLogout) { this._onLogOut = hooks.onLogout; } if (hooks.onLogin) { this.onLogIn = hooks.onLogin; } } else { // No first arg, or first arg is not an object, so this is the legacy invocation this.onEnter = (onEnter as OnNavToPackage) || (() => Promise.resolve()); this.onLeave = onLeave || (() => Promise.resolve()); this._onLogOut = onLogOut || (() => Promise.resolve()); this.onLogIn = onLogIn || (() => Promise.resolve()); } } public enableServerSidePagination(config: ServerSidePaginationExtensionConfig) { console.info(`Extension "${ this.name || this.id }" is enabling server-side pagination for some resources`, config); // eslint-disable-line no-console this.register(EXT_IDS.SERVER_SIDE_PAGINATION_RESOURCES, this.id, () => config); } public async onLogOut(store: any) { await Promise.all(this.stores.map((s: any) => store.dispatch(`${ s.storeName }/onLogout`))); await this._onLogOut(store); } public register(type: string, name: string, fn: Function) { const allowPaths = ['models', 'image']; const nparts = name.split('/'); // Support components in a sub-folder - component_name/index.vue (and ignore other componnets in that folder) // Allow store-scoped models via sub-folder - pkgname/models/storename/type will be registered as storename/type to avoid overwriting shell/models/type if (nparts.length === 2 && !allowPaths.includes(type)) { if (nparts[1] !== 'index') { return; } name = nparts[0]; } // Accumulate l10n resources rather than replace if (type === 'l10n') { if (!this.l10n[name]) { this.l10n[name] = []; } this.l10n[name].push(fn); // Accumulate model extensions } else if (type === EXT_IDS.MODEL_EXTENSION) { if (!this.modelExtensions[name]) { this.modelExtensions[name] = []; } this.modelExtensions[name].push(fn); } else { if (!this.types[type]) { this.types[type] = {}; } this.types[type][name] = fn; } } }