diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a0a0fe2df5..0c05b378d0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -142,6 +142,7 @@ jobs: run: | ls yarn coverage + ls coverage - name: Upload unit test coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/shell/assets/images/generic-plugin.svg b/shell/assets/images/generic-plugin.svg new file mode 100644 index 0000000000..1277beb38c --- /dev/null +++ b/shell/assets/images/generic-plugin.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index 8c3d801276..e946d0cb95 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -43,6 +43,7 @@ generic: info: Info warning: Warning error: Error + ok: OK overview: Overview plusMore: "+ {n} more" readFromFile: Read from File @@ -188,6 +189,7 @@ product: neuvector: NeuVector harvesterManager: Virtualization Management rancher: Rancher + uiplugins: UI Plugins suffix: percent: "%" @@ -598,7 +600,11 @@ asyncButton: install: action: Install success: Installing - waiting: Starting… + waiting: Installing… + load: + action: Load + success: Loaded + waiting: Loadingg… pause: action: Pause Orchestration success: Paused Orchestration @@ -903,6 +909,8 @@ catalog: rancher-charts: '{vendor}' rancher-partner-charts: Partners rancher-rke2-charts: RKE2 + rancher-ui-plugins: Rancher Extensions + target: git: Git repository containing Helm chart or cluster template definitions http: http(s) URL to an index generated by Helm @@ -3645,6 +3653,89 @@ persistentVolumeClaim: readWriteMany: Many Nodes Read-Write status: label: Status + +# UI Plugins +plugins: + labels: + builtin: Built-in + experimental: Experimental + third-party: Third-Party + installing: Installing ... + uninstalling: Uninstalling ... + descriptions: + experimental: This UI Plugin is marked as experimental + third-party: This UI plugin is provided by a Third-Party + error: + title: Error loading plugin + message: Could not load plugin code + success: + title: Loaded plugin {name} + message: Plugin was loaded successfully + developer: + label: Developer Load + title: Developer Load Plugin + prompt: Load a plugin from a URL + fields: + url: Plugin URL + name: Plugin module name + persist: Persist plugin by creating custom resource + info: + detail: Detail + versions: Versions + versionError: Could not load version information + empty: + all: No UI Plugins either installed nor available + available: No UI Plugins available + installed: No UI Plugins installed + updates: No UI Plugin updates available + loadError: An error occurred loading the code for this plugin + helmError: "An error occurred installing the plugin via Helm" + tabs: + all: All + available: Available + installed: Installed + updates: Updates + title: UI Plugins + install: + label: Install + title: Install UI Plugin {name} + prompt: "Are you sure that you want to install this UI Plugin?" + version: Version + warnNotCertified: Please ensure that you are aware of the risks of installing UI Plugins from untrusted authors + update: + label: Update + title: Update UI Plugin {name} + prompt: "Are you sure that you want to update this UI Plugin?" + rollback: + label: Rollback + title: Rollback UI Plugin {name} + prompt: "Are you sure that you want to rollback this UI Plugin?" + uninstall: + label: Uninstall + title: "Uninstall UI Plugin: {name}" + prompt: "Are you sure that you want to uninstall this UI Plugin?" + upgradeAvailable: A newer version of this UI Plugin is available + safeMode: + title: UI Plugins Safe Mode + message: UI Plugins were not loaded + setup: + title: UI Plugin Support is not enabled + prompt: + cant: Automatic installation is not available - required Helm Charts could not be found + can: You need to install the Plugin Operator + install: + title: Enable UI Plugin Support? + prompt: This will install the Helm charts to enable Rancher UI Plugin support + airgap: Un-check if your Rancher installation is air-gapped + addRancherRepo: Add the Rancher UI Plugins Repository + remove: + label: Disable UI Plugin Support + title: Disable UI Plugin Support? + prompt: This will un-install the Helm charts that enable Rancher UI Plugin support + registry: + title: Remove the Rancher UI Plugins Repository + prompt: Remove the default UI Plugins Repository + prefs: title: Preferences theme: @@ -3683,6 +3774,7 @@ prefs: viewInApi: Enable "View in API" allNamespaces: Show system Namespaces managed by Rancher (not intended for editing or deletion) themeShortcut: Enable Dark/Light Theme keyboard shortcut toggle (shift+T) + pluginDeveloper: Enable UI Plugin Developer features hideDesc: label: Hide All Type Descriptions helm: diff --git a/shell/components/AsyncButton.vue b/shell/components/AsyncButton.vue index 20895853d6..be0e8bf4da 100644 --- a/shell/components/AsyncButton.vue +++ b/shell/components/AsyncButton.vue @@ -95,7 +95,13 @@ export default Vue.extend({ currentPhase: { type: String, default: ASYNC_BUTTON_STATES.ACTION, - } + }, + + manual: { + type: Boolean, + default: false, + }, + }, data(): { phase: string, timer?: NodeJS.Timeout} { @@ -190,6 +196,12 @@ export default Vue.extend({ } }, + beforeDestroy() { + if (this.timer) { + clearTimeout(this.timer); + } + }, + methods: { clicked($event: MouseEvent) { if ($event) { @@ -205,7 +217,10 @@ export default Vue.extend({ clearTimeout(this.timer); } - this.phase = ASYNC_BUTTON_STATES.WAITING; + // If manual property is set, don't automatically change the button on click + if (!this.manual) { + this.phase = ASYNC_BUTTON_STATES.WAITING; + } const cb: AsyncButtonCallback = (success) => { this.done(success); diff --git a/shell/components/Dialog.vue b/shell/components/Dialog.vue new file mode 100644 index 0000000000..ab90332880 --- /dev/null +++ b/shell/components/Dialog.vue @@ -0,0 +1,102 @@ + + + + + + + {{ title }} + + + + + + + {{ t('generic.cancel') }} + + + {{ t('generic.ok') }} + + + + + + + + + diff --git a/shell/components/IconMessage.vue b/shell/components/IconMessage.vue index 4222f0c366..57be4e8069 100644 --- a/shell/components/IconMessage.vue +++ b/shell/components/IconMessage.vue @@ -21,12 +21,16 @@ export default { type: String, default: null }, + subtle: { + type: Boolean, + default: false, + } }, }; - + @@ -47,6 +51,10 @@ export default { width: 100%; } + .subtle { + opacity: 0.7; + } + .message-icon { display: flex; align-items: center; diff --git a/shell/components/Tabbed/Tab.vue b/shell/components/Tabbed/Tab.vue index 9c23979993..8a2c9603fb 100644 --- a/shell/components/Tabbed/Tab.vue +++ b/shell/components/Tabbed/Tab.vue @@ -36,6 +36,11 @@ export default { type: Boolean, default: false }, + badge: { + default: 0, + required: false, + type: Number + }, }, data() { diff --git a/shell/components/Tabbed/index.vue b/shell/components/Tabbed/index.vue index 82ebfccd47..7053d4e881 100644 --- a/shell/components/Tabbed/index.vue +++ b/shell/components/Tabbed/index.vue @@ -38,6 +38,11 @@ export default { noContent: { type: Boolean, default: false, + }, + + tabsOnly: { + type: Boolean, + default: false, } }, @@ -207,7 +212,7 @@ export default { - + {{ tab.labelDisplay }} + {{ tab.badge }} @@ -326,6 +332,15 @@ export default { color: var(--error); } } + + .tab-badge { + margin-left: 5px; + background-color: var(--link); + color: #fff; + border-radius: 6px; + padding: 1px 7px; + font-size: 11px; + } } } @@ -337,6 +352,19 @@ export default { } } +.tabs-only { + margin-bottom: 20px; + + .tab-container { + display: none; + } + + .tabs { + border: 0; + border-bottom: 2px solid var(--border); + } +} + .side-tabs { display: flex; box-shadow: 0 0 20px var(--shadow); diff --git a/shell/components/nav/TopLevelMenu.vue b/shell/components/nav/TopLevelMenu.vue index 58a1cc8e08..0fe8c78950 100644 --- a/shell/components/nav/TopLevelMenu.vue +++ b/shell/components/nav/TopLevelMenu.vue @@ -120,22 +120,7 @@ export default { configurationApps() { const options = this.options; - const items = options.filter(opt => opt.category === 'configuration'); - - // Add plugin page - // Ony when developing for now - if (process.env.dev) { - items.push({ - label: 'Plugins', - inStore: 'management', - icon: 'icon-gear', - value: 'plugins', - weight: 1, - to: { name: 'plugins' }, - }); - } - - return items; + return options.filter(opt => opt.category === 'configuration'); }, options() { diff --git a/shell/config/product/uiplugins.js b/shell/config/product/uiplugins.js new file mode 100644 index 0000000000..938767a702 --- /dev/null +++ b/shell/config/product/uiplugins.js @@ -0,0 +1,17 @@ +import { DSL, IF_HAVE } from '@shell/store/type-map'; + +export const NAME = 'uiplugins'; + +export function init(store) { + const { product } = DSL(store, NAME); + + // Add a product for UI Plugins - will appear in the top-level menu + product({ + ifHave: IF_HAVE.ADMIN, // Only admins can see the UI Plugin Custom Resource by default + inStore: 'management', + icon: 'gear', + removable: false, + showClusterSwitcher: false, + category: 'configuration', + }); +} diff --git a/shell/config/types.js b/shell/config/types.js index 387f7a2720..9aa57772ba 100644 --- a/shell/config/types.js +++ b/shell/config/types.js @@ -100,6 +100,9 @@ export const CATALOG = { REPO: 'catalog.cattle.io.repo', }; +// UI Plugin type +export const UI_PLUGIN = 'catalog.cattle.io.uiplugin'; + export const HELM = { PROJECTHELMCHART: 'helm.cattle.io.projecthelmchart' }; export const MONITORING = { diff --git a/shell/config/uiplugins.js b/shell/config/uiplugins.js new file mode 100644 index 0000000000..6556f6e197 --- /dev/null +++ b/shell/config/uiplugins.js @@ -0,0 +1,60 @@ +import semver from 'semver'; + +// Version of the plugin API supported +export const UI_PLUGIN_API_VERSION = '1.0.0'; + +export const UI_PLUGIN_BASE_URL = '/api/v1/namespaces/cattle-ui-plugin-system/services/http:ui-plugin-operator:80/proxy'; + +export const UI_PLUGIN_NAMESPACE = 'cattle-ui-plugin-system'; + +// Annotation name and value that indicate a chart is a UI plugin +export const UI_PLUGIN_ANNOTATION_NAME = 'catalog.cattle.io/ui-component'; +export const UI_PLUGIN_ANNOTATION_VALUE = 'plugins'; + +export const UI_PLUGIN_OPERATOR_CRD_CHART_NAME = 'ui-plugin-operator-crd'; +export const UI_PLUGIN_OPERATOR_CHART_NAME = 'ui-plugin-operator'; + +export const UI_PLUGIN_CHARTS = [ + UI_PLUGIN_OPERATOR_CHART_NAME, + UI_PLUGIN_OPERATOR_CRD_CHART_NAME, +]; + +// Expected chart repo name for the UI Plugins operator +export const UI_PLUGIN_OPERATOR_REPO_NAME = 'rancher-charts'; + +// Info for the Helm Chart Repository that we will add +export const UI_PLUGINS_REPO_NAME = 'rancher-ui-plugins'; + +export const UI_PLUGINS_REPO_URL = 'https://github.com/rancher/ui-plugin-charts'; +export const UI_PLUGINS_REPO_BRANCH = 'main'; + +// Plugin Metadata properties +const UI_PLUGIN_METADATA_API_VERSION = 'apiVersion'; + +export function isUIPlugin(chart) { + return !!chart?.versions.find((v) => { + return v.annotations && v.annotations[UI_PLUGIN_ANNOTATION_NAME] === UI_PLUGIN_ANNOTATION_VALUE; + }); +} + +export function uiPluginHasAnnotation(chart, name, value) { + return !!chart?.versions.find((v) => { + return v.annotations && v.annotations[name] === value; + }); +} + +// Should we load a plugin, based on the metadata returned by the backend? +export function shouldLoadPlugin(plugin) { + if (!plugin.name || !plugin.version || !plugin.endpoint) { + return false; + } + + // Plugin specified a required API version + const requiredAPI = plugin.metadata?.[UI_PLUGIN_METADATA_API_VERSION]; + + if (requiredAPI) { + return semver.satisfies(UI_PLUGIN_API_VERSION, requiredAPI); + } + + return true; +} diff --git a/shell/core/plugins.js b/shell/core/plugins.js index 8697d8426e..184f7d2263 100644 --- a/shell/core/plugins.js +++ b/shell/core/plugins.js @@ -2,6 +2,7 @@ import { productsLoaded } from '@shell/store/type-map'; import { clearModelCache } from '@shell/plugins/dashboard-store/model-loader'; import { Plugin } from './plugin'; import { PluginRoutes } from './plugin-routes'; +import { UI_PLUGIN_BASE_URL } from '@shell/config/uiplugins'; const MODEL_TYPE = 'models'; @@ -35,6 +36,17 @@ export default function({ return internal; }, + // Load a plugin from a UI package + loadAsyncByNameAndVersion(name, version, url) { + const id = `${ name }-${ version }`; + + if (!url) { + url = `${ UI_PLUGIN_BASE_URL }/${ name }/${ version }/plugin/${ id }.umd.min.js`; + } + + return this.loadAsync(id, url); + }, + // Load a plugin from a UI package loadAsync(id, mainFile) { return new Promise((resolve, reject) => { diff --git a/shell/models/catalog.cattle.io.uiplugin.js b/shell/models/catalog.cattle.io.uiplugin.js new file mode 100644 index 0000000000..bec82ab74a --- /dev/null +++ b/shell/models/catalog.cattle.io.uiplugin.js @@ -0,0 +1,34 @@ +import SteveModel from '@shell/plugins/steve/steve-class'; + +const CACHED_STATUS = 'cached'; + +export default class UIPlugin extends SteveModel { + get name() { + return this.spec?.plugin?.name; + } + + get description() { + return this.spec?.plugin?.description; + } + + get version() { + return this.spec?.plugin?.version; + } + + get willBeCached() { + return this.spec?.plugin?.noCache === false; + } + + // Has the plugin been cached? + get isCached() { + return !this.willBeCached || (this.willBeCached && this.status?.cacheState === CACHED_STATUS); + } + + get pluginMetadata() { + return this.spec?.plugin?.metadata || {}; + } + + get isDeveloper() { + return this.pluginMetadata?.developer === 'true'; + } +} diff --git a/shell/nuxt.config.js b/shell/nuxt.config.js index c765b57f8a..8c904c0e49 100644 --- a/shell/nuxt.config.js +++ b/shell/nuxt.config.js @@ -185,8 +185,6 @@ export default function(dir, _appConfig) { // Serve up the dist-pkg folder under /pkg serverMiddleware.push({ path: `/pkg/`, handler: serveStatic(`${ dir }/dist-pkg/`) }); - // Endpoint to download and unpack a tgz from the local verdaccio rgistry (dev) - serverMiddleware.push(path.resolve(dir, SHELL, 'server', 'verdaccio-middleware')); // Add the standard dashboard server middleware after the middleware added to serve up UI packages serverMiddleware.push(path.resolve(dir, SHELL, 'server', 'server-middleware')); @@ -555,7 +553,7 @@ export default function(dir, _appConfig) { plugins: [ // Extensions path.relative(dir, path.join(SHELL, 'core/plugins.js')), - path.relative(dir, path.join(SHELL, 'core/plugins-loader.js')), + path.relative(dir, path.join(SHELL, 'core/plugins-loader.js')), // Load builtin plugins // Third-party path.join(NUXT_SHELL, 'plugins/axios'), @@ -579,7 +577,7 @@ export default function(dir, _appConfig) { { src: path.join(NUXT_SHELL, 'plugins/nuxt-client-init'), ssr: false }, path.join(NUXT_SHELL, 'plugins/replaceall'), path.join(NUXT_SHELL, 'plugins/back-button'), - { src: path.join(NUXT_SHELL, 'plugins/plugin'), ssr: false }, + { src: path.join(NUXT_SHELL, 'plugins/plugin'), ssr: false }, // Load dyanmic plugins { src: path.join(NUXT_SHELL, 'plugins/codemirror-loader'), ssr: false }, { src: path.join(NUXT_SHELL, 'plugins/formatters'), ssr: false }, // Populate formatters cache for sorted table ], @@ -605,8 +603,6 @@ export default function(dir, _appConfig) { '/assets': proxyOpts('https://127.0.0.1:8000'), '/translations': proxyOpts('https://127.0.0.1:8000'), '/engines-dist': proxyOpts('https://127.0.0.1:8000'), - // Plugin dev - '/verdaccio/': proxyOpts('http://127.0.0.1:4873/-'), }, // Nuxt server diff --git a/shell/pages/c/_cluster/apps/charts/index.vue b/shell/pages/c/_cluster/apps/charts/index.vue index 1e96f8567c..12b150770b 100644 --- a/shell/pages/c/_cluster/apps/charts/index.vue +++ b/shell/pages/c/_cluster/apps/charts/index.vue @@ -18,6 +18,7 @@ import { mapPref, HIDE_REPOS, SHOW_PRE_RELEASE, SHOW_CHART_MODE } from '@shell/s import { removeObject, addObject, findBy } from '@shell/utils/array'; import { compatibleVersionsFor, filterAndArrangeCharts } from '@shell/store/catalog'; import { CATALOG } from '@shell/config/labels-annotations'; +import { isUIPlugin } from '@shell/config/uiplugins'; export default { components: { @@ -149,6 +150,10 @@ export default { return false; } + if (isUIPlugin(c)) { + return false; + } + return true; }); }, diff --git a/shell/pages/c/_cluster/uiplugins/DeveloperInstallDialog.vue b/shell/pages/c/_cluster/uiplugins/DeveloperInstallDialog.vue new file mode 100644 index 0000000000..66b8d6fe79 --- /dev/null +++ b/shell/pages/c/_cluster/uiplugins/DeveloperInstallDialog.vue @@ -0,0 +1,232 @@ + + + + + + + {{ t('plugins.developer.title') }} + + + {{ t('plugins.developer.prompt') }} + + + + + + + + + + + + + + + + {{ t('generic.cancel') }} + + + + + + + + + diff --git a/shell/pages/c/_cluster/uiplugins/InstallDialog.vue b/shell/pages/c/_cluster/uiplugins/InstallDialog.vue new file mode 100644 index 0000000000..7f05aaa5b5 --- /dev/null +++ b/shell/pages/c/_cluster/uiplugins/InstallDialog.vue @@ -0,0 +1,242 @@ + + + + + + + {{ t(`plugins.${ mode }.title`, { name: plugin.name }) }} + + + + + {{ t(`plugins.${ mode }.prompt`) }} + + + + + {{ t('plugins.install.version') }} {{ version }} + + + + + {{ t('generic.cancel') }} + + + + + + + + + diff --git a/shell/pages/c/_cluster/uiplugins/PluginInfoPanel.vue b/shell/pages/c/_cluster/uiplugins/PluginInfoPanel.vue new file mode 100644 index 0000000000..47fa949e40 --- /dev/null +++ b/shell/pages/c/_cluster/uiplugins/PluginInfoPanel.vue @@ -0,0 +1,284 @@ + + + + + + + + + + + + + + {{ info.name }} + + + {{ info.description }} + + + + + + + + + + + + + + + + + {{ t('plugins.info.versions') }} + + + + + {{ v.version }} + + + + + + {{ t('plugins.info.versionError') }} + + + + {{ t('plugins.info.detail') }} + + + + + + + + diff --git a/shell/pages/c/_cluster/uiplugins/RemoveUIPlugins.vue b/shell/pages/c/_cluster/uiplugins/RemoveUIPlugins.vue new file mode 100644 index 0000000000..0a6600afa2 --- /dev/null +++ b/shell/pages/c/_cluster/uiplugins/RemoveUIPlugins.vue @@ -0,0 +1,130 @@ + + + + + + {{ t('plugins.setup.remove.prompt') }} + + + + + {{ t('plugins.setup.remove.registry.prompt') }} + + + + + + diff --git a/shell/pages/c/_cluster/uiplugins/SetupUIPlugins.vue b/shell/pages/c/_cluster/uiplugins/SetupUIPlugins.vue new file mode 100644 index 0000000000..ed0a156310 --- /dev/null +++ b/shell/pages/c/_cluster/uiplugins/SetupUIPlugins.vue @@ -0,0 +1,253 @@ + + + + + + + {{ t('plugins.setup.title') }} + + + + + {{ t('plugins.setup.prompt.cant') }} + + + + + {{ t('plugins.setup.prompt.can') }} + + + + + {{ e }} + + + + + + + + {{ t('plugins.setup.install.prompt') }} + + + + + {{ t('plugins.setup.install.airgap') }} + + + + + + + diff --git a/shell/pages/c/_cluster/uiplugins/UninstallDialog.vue b/shell/pages/c/_cluster/uiplugins/UninstallDialog.vue new file mode 100644 index 0000000000..326b9d2d91 --- /dev/null +++ b/shell/pages/c/_cluster/uiplugins/UninstallDialog.vue @@ -0,0 +1,115 @@ + + + + + + + {{ t('plugins.uninstall.title', { name: plugin.name }) }} + + + + + {{ t('plugins.uninstall.prompt') }} + + + + + {{ t('generic.cancel') }} + + + + + + + + + diff --git a/shell/pages/c/_cluster/uiplugins/index.vue b/shell/pages/c/_cluster/uiplugins/index.vue new file mode 100644 index 0000000000..3eadc70cc8 --- /dev/null +++ b/shell/pages/c/_cluster/uiplugins/index.vue @@ -0,0 +1,694 @@ + + + + + + {{ t('plugins.title') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ plugin.name }} + + {{ plugin.description }} + + {{ t('plugins.labels.builtin') }} + + + + + + {{ t('plugins.labels.installing') }} + + + {{ t('plugins.labels.uninstalling') }} + + + + {{ plugin.displayVersion }} + -> {{ plugin.upgrade }} + + + + + {{ t('plugins.labels.third-party') }} + + + {{ t('plugins.labels.experimental') }} + + + + + + + + + + + + + + + + + + + {{ t('plugins.uninstall.label') }} + + + {{ t('plugins.update.label') }} + + + {{ t('plugins.rollback.label') }} + + + + + {{ t('plugins.install.label') }} + + + + + + + + + + + + + + + + + diff --git a/shell/pages/plugins.vue b/shell/pages/plugins.vue deleted file mode 100644 index 83d0d34e68..0000000000 --- a/shell/pages/plugins.vue +++ /dev/null @@ -1,387 +0,0 @@ - - - - - - Plugins - - Add Catalog - - - Load - - - - - - - - - - - - {{ plugin.name }} - - {{ plugin.description }} - - Built-in plugin - - - {{ plugin.displayVersion }} - - - - - - Uninstall - - - - - - Install - - - - - - - - - - Load Plugin - - - - - - - - - - - - - Cancel - - - Load Plugin - - - - - - - - - - Add Plugin Catalog - - - - - - - - Cancel - - - Add - - - - - - - - - diff --git a/shell/pages/prefs.vue b/shell/pages/prefs.vue index 8bf2b9271f..df3c63bec2 100644 --- a/shell/pages/prefs.vue +++ b/shell/pages/prefs.vue @@ -1,6 +1,7 @@ diff --git a/shell/plugins/plugin.js b/shell/plugins/plugin.js index ff7dacf314..d49d8eb22e 100644 --- a/shell/plugins/plugin.js +++ b/shell/plugins/plugin.js @@ -1,10 +1,11 @@ -// This plugin loads any extensions at app load time - -import { allHash } from '@shell/utils/promise'; +// This plugin loads any UI Plugins at app load time +import { allHashSettled } from '@shell/utils/promise'; +import { shouldLoadPlugin, UI_PLUGIN_BASE_URL } from '@shell/config/uiplugins'; const META_NAME_PREFIX = 'app-autoload-'; export default async(context) => { + // UI Plugins declared in the HTML head const meta = context.app?.head?.meta || []; const hash = {}; @@ -18,5 +19,56 @@ export default async(context) => { } }); - await allHash(hash); + // Provide a mechanism to load the UI without the plugins loaded - in case there is a problem + let loadPlugins = true; + + if (context.route?.path.endsWith('/safeMode')) { + loadPlugins = false; + console.warn('Safe Mode - plugins will not be loaded'); // eslint-disable-line no-console + } + + if (loadPlugins) { + const { store, $plugin } = context; + + // Fetch list of installed plugins from endpoint + try { + const res = await store.dispatch('management/request', { + url: `${ UI_PLUGIN_BASE_URL }/index.json`, + headers: { accept: 'application/json' } + }); + + if (res) { + const entries = res.entries || res.Entries || {}; + + Object.values(entries).forEach((plugin) => { + if (shouldLoadPlugin(plugin)) { + let url; + + if (plugin?.metadata?.['direct'] === 'true') { + url = plugin.endpoint; + } + + hash[plugin.name] = $plugin.loadAsyncByNameAndVersion(plugin.name, plugin.version, url); + } + }); + } + } catch (e) { + console.error('Could not load UI Plugin list', e); // eslint-disable-line no-console + } + + // Load all of the plugins + const pluginLoads = await allHashSettled(hash); + + // Some pluigns may have failed to load - store this + Object.keys(pluginLoads).forEach((name) => { + const res = pluginLoads[name]; + + if (res?.status === 'rejected') { + console.error(`Failed to load plugin: ${ name }`); // eslint-disable-line no-console + + // Record error in the uiplugins store, so that we can show this to the user + context.store.dispatch('uiplugins/setError', { name, error: true }); + } + }); + } }; diff --git a/shell/server/verdaccio-middleware.js b/shell/server/verdaccio-middleware.js deleted file mode 100644 index e8a23737b9..0000000000 --- a/shell/server/verdaccio-middleware.js +++ /dev/null @@ -1,56 +0,0 @@ -import http from 'http'; -import path from 'path'; -import fs from 'fs-extra'; -import tar from 'tar'; - -export default function(req, res, next) { - if (req.url.startsWith('/download-pkg')) { - const urlp = req.url.split('/'); - const pkg = urlp[urlp.length - 1]; - const name = pkg.split('-')[0]; - - const downloadFolder = path.resolve(__dirname, '..', '..', 'dist-pkg'); - const pkgFolder = path.resolve(downloadFolder, pkg); - - // If the package is already there, use that - if (fs.existsSync(pkgFolder)) { - res.statusCode = 200; - res.write('OK'); - - return res.end(); - } - - // Need to download - const url = `http://127.0.0.1:4873/${ name }/-/${ pkg }.tgz`; - const destPath = path.join(downloadFolder, `${ pkg }.tgz`); - const dest = fs.createWriteStream(destPath); - - dest.on('open', () => { - http.get(url, (response) => { - response.pipe(dest); - }).on('error', () => { - fs.unlink(dest); - res.status = 500; - res.write('ERROR'); - res.end(); - }); - }); - - dest.on('finish', () => { - dest.close(() => { - fs.ensureDirSync(pkgFolder); - tar.x({ - file: destPath, - strip: 1, - C: pkgFolder - }).then(() => { - res.statusCode = 200; - res.write('OK'); - res.end(); - }); - }); - }); - } else { - next(); - } -} diff --git a/shell/store/catalog.js b/shell/store/catalog.js index 8e9bd191dd..f1924d47c0 100644 --- a/shell/store/catalog.js +++ b/shell/store/catalog.js @@ -61,6 +61,11 @@ export const getters = { return [...clustered, ...namespaced]; }, + // Raw charts + rawCharts(state) { + return state.charts; + }, + repo(state, getters) { return ({ repoType, repoName }) => { const ary = (repoType === 'cluster' ? state.clusterRepos : state.namespacedRepos); @@ -340,6 +345,7 @@ export const actions = { // Installing an app? This is fine (in cluster store) // Fetching list of cluster templates? This is fine (in management store) // Installing a cluster template? This isn't fine (in cluster store as per installing app, but if there is no cluster we need to default to management) + const inStore = rootGetters['currentCluster'] ? rootGetters['currentProduct'].inStore : 'management'; if ( rootGetters[`${ inStore }/schemaFor`](CATALOG.CLUSTER_REPO) ) { diff --git a/shell/store/prefs.js b/shell/store/prefs.js index 8b2c66813d..f2bb341e16 100644 --- a/shell/store/prefs.js +++ b/shell/store/prefs.js @@ -105,6 +105,7 @@ export const SEEN_WHATS_NEW = create('seen-whatsnew', '', { parseJSON }); export const READ_WHATS_NEW = create('read-whatsnew', '', { parseJSON }); export const AFTER_LOGIN_ROUTE = create('after-login-route', 'home', { parseJSON } ); export const HIDE_HOME_PAGE_CARDS = create('home-page-cards', {}, { parseJSON } ); +export const PLUGIN_DEVELOPER = create('plugin-developer', false, { parseJSON, inheritFrom: DEV }); // Is the user a plugin developer? export const _RKE1 = 'rke1'; export const _RKE2 = 'rke2'; diff --git a/shell/store/type-map.js b/shell/store/type-map.js index 34f2b4339e..f24e445081 100644 --- a/shell/store/type-map.js +++ b/shell/store/type-map.js @@ -168,6 +168,7 @@ export const IF_HAVE = { NOT_V1_ISTIO: 'not-v1-istio', MULTI_CLUSTER: 'multi-cluster', NEUVECTOR_NAMESPACE: 'neuvector-namespace', + ADMIN: 'admin-user', }; export function DSL(store, product, module = 'type-map') { @@ -1732,11 +1733,22 @@ function ifHave(getters, option) { case IF_HAVE.NEUVECTOR_NAMESPACE: { return getters[`cluster/all`](NAMESPACE).find(n => n.metadata.name === NEU_VECTOR_NAMESPACE); } + case IF_HAVE.ADMIN: { + return isAdminUser(getters); + } default: return false; } } +// Could list a larger set of resources that typically only an admin user would have +export function isAdminUser(getters) { + const canEditSettings = (getters['management/schemaFor'](MANAGEMENT.SETTING)?.resourceMethods || []).includes('PUT'); + const canEditFeatureFlags = (getters['management/schemaFor'](MANAGEMENT.FEATURE)?.resourceMethods || []).includes('PUT'); + + return canEditSettings && canEditFeatureFlags; +} + // Is V1 Istio installed? function isV1Istio(getters) { const cluster = getters['currentCluster']; diff --git a/shell/store/uiplugins.ts b/shell/store/uiplugins.ts index 5a57c6f92c..92284d98cf 100644 --- a/shell/store/uiplugins.ts +++ b/shell/store/uiplugins.ts @@ -3,20 +3,22 @@ // import { addObject, removeObject } from '@shell/utils/array'; -import { allHash } from '@shell/utils/promise'; import { Plugin } from '@shell/core/plugin'; interface UIPluginState { plugins: Plugin[], - catalog: any[], - catalogs: string[], + errors: any, +} + +interface LoadError { + name: string, + error: boolean, } export const state = function(): UIPluginState { return { plugins: [], - catalog: [], - catalogs: [''], + errors: {}, }; }; @@ -25,16 +27,16 @@ export const getters = { return state.plugins; }, - catalog: (state: any) => { - return state.catalog; - }, - - catalogs: (state: any) => { - return state.catalogs; + errors: (state: any) => { + return state.errors; }, }; export const mutations = { + setError(state: UIPluginState, error: LoadError) { + state.errors[error.name] = error.error; + }, + addPlugin(state: UIPluginState, plugin: Plugin) { // TODO: Duplicates? state.plugins.push(plugin); @@ -47,59 +49,11 @@ export const mutations = { state.plugins.splice(index, 1); } }, - - setCatalog(state: UIPluginState, catalog: any) { - state.catalog = catalog; - }, - - addCatalog(state: UIPluginState, catalog: string) { - state.catalogs.push(catalog); - } }; export const actions = { - addCatalog( { commit, dispatch }: any, url: string ) { - commit('addCatalog', url); - }, - - // This is just for PoC - we wouldn't get the catalog from Verdaccio - // This fetches the catalog each time - async loadCatalogs( { getters, commit, dispatch }: any) { - const packages: any[] = []; - const catalogHash = {} as any; - const catalogs = getters['catalogs']; - - catalogs.forEach((url: string) => { - const base = url; - - if (!url) { - url = '/verdaccio/data/packages'; - } else { - url = `/uiplugins-catalog/?${ url }`; - } - - try { - catalogHash[base] = dispatch('rancher/request', { url }, { root: true }); - } catch (err) { - // Ignore errors... or all plugins fail - console.warn('Unable to fetch catalog: ', url, err); // eslint-disable-line no-console - } - }); - - const res = await allHash(catalogHash); - - Object.keys(res as any).forEach((r: string) => { - const v: any = (res as any)[r]; - - v.forEach((p: any) => { - p.location = r; - packages.push(p); - }); - }); - - const uiPackages = packages.filter((pkg: any) => pkg.rancher); - - commit('setCatalog', uiPackages); + setError( { commit }: any, error: LoadError ) { + commit('setError', error); }, addPlugin({ commit }: any, plugin: Plugin) {
+ {{ t('plugins.developer.prompt') }} +
+ {{ t(`plugins.${ mode }.prompt`) }} +
+ {{ info.description }} +
+ {{ t('plugins.setup.remove.prompt') }} +
+ {{ t('plugins.setup.prompt.cant') }} +
+ {{ t('plugins.setup.prompt.can') }} +
+ {{ t('plugins.setup.install.prompt') }} +
+ {{ t('plugins.uninstall.prompt') }} +