Plugins: Add UI to manage plugins (#6973)

* WIP: Working version

* Further refinement

* Working version

* Refactor to a product

* i18n

* Fix lint and tidy comments

* Empty-Commit

* Bump e2e

* Latest fixes, i18n

* Fix lint

* Fix lint issues

* Fix imports for standlone plugin build

* Only load plugins in dev

* Fix lint issue

* Fix template errors

* Fix operator setup

* Fix menu actions

* Address PR feedback

* Address PR feedback

* Add new preference for plugin developer

* Update icon support

* Add third-party and experimental banners to slide-in

* Add support for update/rollback of a plugin

* Address PR feedback - i18n

* i18n - one more string localised

* i18n - one more string localised

* Minor visual tidy ups

* Use banner for install warning

* Fix saefMode

* Fix lint

* Add some responsiveness to the cards page

* Fix lint

* Bump PR

* Add debug to list coverage reports
This commit is contained in:
Neil MacDougall 2022-10-04 13:16:54 +01:00 committed by GitHub
parent bd1a8abd0a
commit 80eeacfb41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 2466 additions and 538 deletions

View File

@ -142,6 +142,7 @@ jobs:
run: |
ls
yarn coverage
ls coverage
- name: Upload unit test coverage to Codecov
uses: codecov/codecov-action@v3

View File

@ -0,0 +1,7 @@
<svg width="36px" height="36px" viewBox="0 0 36 36" version="1.1" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<style type="text/css">
.st0{fill:#777;}
</style>
<path d="M29.81,16H29V8.83a2,2,0,0,0-2-2H21A5.14,5.14,0,0,0,16.51,2,5,5,0,0,0,11,6.83H4a2,2,0,0,0-2,2V17H4.81A3.13,3.13,0,0,1,8,19.69,3,3,0,0,1,7.22,22,3,3,0,0,1,5,23H2v8.83a2,2,0,0,0,2,2H27a2,2,0,0,0,2-2V26h1a5,5,0,0,0,5-5.51A5.15,5.15,0,0,0,29.81,16Zm2.41,7A3,3,0,0,1,30,24H27v7.83H4V25H5a5,5,0,0,0,5-5.51A5.15,5.15,0,0,0,4.81,15H4V8.83h9V7a3,3,0,0,1,1-2.22A3,3,0,0,1,16.31,4,3.13,3.13,0,0,1,19,7.19V8.83h8V18h2.81A3.13,3.13,0,0,1,33,20.69,3,3,0,0,1,32.22,23Z" class="clr-i-outline clr-i-outline-path-1 st0"></path>
<rect class="st0" x="0" y="0" width="36" height="36" fill-opacity="0"/>
</svg>

After

Width:  |  Height:  |  Size: 844 B

View File

@ -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&hellip;
waiting: Installing&hellip;
load:
action: Load
success: Loaded
waiting: Loadingg&hellip;
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:

View File

@ -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);

102
shell/components/Dialog.vue Normal file
View File

@ -0,0 +1,102 @@
<script>
import AsyncButton from '@shell/components/AsyncButton';
export default {
components: { AsyncButton },
props: {
name: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
mode: {
type: String,
default: '',
}
},
data() {
return { closed: false };
},
methods: {
beforeOpen() {
this.closed = false;
},
ok(btnCb) {
const callback = (ok) => {
btnCb(ok);
if (ok) {
this.closeDialog(true);
}
};
this.$emit('okay', callback);
},
closeDialog(result) {
if (!this.closed) {
this.$modal.hide(this.name);
this.$emit('closed', result);
this.closed = true;
}
},
}
};
</script>
<template>
<modal
:name="name"
height="auto"
:scrollable="true"
@closed="closeDialog(false)"
@before-open="beforeOpen"
>
<div class="modal-dialog">
<h4>
{{ title }}
</h4>
<slot></slot>
<div class="dialog-buttons mt-20">
<slot name="buttons"></slot>
<div v-if="!$slots.buttons">
<button class="btn role-secondary" @click="closeDialog(false)">
{{ t('generic.cancel') }}
</button>
<button v-if="!mode" class="btn role-primary ml-10" @click="closeDialog(true)">
{{ t('generic.ok') }}
</button>
<AsyncButton v-else :mode="mode" class="ml-10" @click="ok" />
</div>
</div>
</div>
</modal>
</template>
<style lang="scss" scoped>
.modal-dialog {
padding: 10px;
h4 {
font-weight: bold;
}
.dialog-buttons {
display: flex;
justify-content: flex-end;
margin-top: 10px;
> *:not(:last-child) {
margin-right: 10px;
}
}
}
</style>

View File

@ -21,12 +21,16 @@ export default {
type: String,
default: null
},
subtle: {
type: Boolean,
default: false,
}
},
};
</script>
<template>
<div class="message-icon" :class="{'vertical': vertical}">
<div class="message-icon" :class="{'vertical': vertical, 'subtle': subtle}">
<i class="icon" :class="{ [icon]: true, [iconState]: !!iconState}" />
<div class="message">
<slot name="message">
@ -47,6 +51,10 @@ export default {
width: 100%;
}
.subtle {
opacity: 0.7;
}
.message-icon {
display: flex;
align-items: center;

View File

@ -36,6 +36,11 @@ export default {
type: Boolean,
default: false
},
badge: {
default: 0,
required: false,
type: Number
},
},
data() {

View File

@ -38,6 +38,11 @@ export default {
noContent: {
type: Boolean,
default: false,
},
tabsOnly: {
type: Boolean,
default: false,
}
},
@ -207,7 +212,7 @@ export default {
</script>
<template>
<div :class="{'side-tabs': !!sideTabs }">
<div :class="{'side-tabs': !!sideTabs, 'tabs-only': tabsOnly }">
<ul
ref="tablist"
role="tablist"
@ -233,6 +238,7 @@ export default {
@click.prevent="select(tab.name, $event)"
>
<span>{{ tab.labelDisplay }}</span>
<span v-if="tab.badge" class="tab-badge">{{ tab.badge }}</span>
<i v-if="hasIcon(tab)" v-tooltip="t('validation.tab')" class="conditions-alert-icon icon-error icon-lg" />
</a>
</li>
@ -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);

View File

@ -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() {

View File

@ -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',
});
}

View File

@ -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 = {

60
shell/config/uiplugins.js Normal file
View File

@ -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;
}

View File

@ -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) => {

View File

@ -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';
}
}

View File

@ -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

View File

@ -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;
});
},

View File

@ -0,0 +1,232 @@
<script>
import AsyncButton from '@shell/components/AsyncButton';
import { LabeledInput } from '@components/Form/LabeledInput';
import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
import { UI_PLUGIN } from '@shell/config/types';
import { UI_PLUGIN_NAMESPACE } from '@shell/config/uiplugins';
export default {
components: {
AsyncButton,
Checkbox,
LabeledInput,
},
data() {
return {
name: '',
location: '',
persist: false,
canModifyName: true,
canModifyLocation: true,
};
},
watch: {
name(neu, old) {
if (this.canModifyLocation) {
this.location = `/pkg/${ neu }/${ neu }.umd.min.js`;
}
},
location(neu, old) {
if (this.canModifyName) {
const names = neu.split('/');
let last = names[names.length - 1];
let index = last.indexOf('.umd.min.js');
if (index !== -1) {
last = last.substr(0, index);
} else {
index = last.indexOf('.umd.js');
if (index !== -1) {
last = last.substr(0, index);
}
}
this.name = last;
}
}
},
methods: {
showDialog() {
this.$modal.show('developerInstallPluginDialog');
},
closeDialog(result) {
this.$modal.hide('developerInstallPluginDialog');
this.$emit('closed', result);
},
updateName(v) {
this.canModifyName = v.length === 0;
},
updateLocation(v) {
this.canModifyLocation = v.length === 0;
},
async loadPlugin(btnCb) {
let name = this.name;
const url = this.location;
if (!name) {
const parts = url.split('/');
const n = parts[parts.length - 1];
// Split on '.'
name = n.split('.')[0];
}
// Try and parse version number from the name
let version = '0.0.1';
let crdName = name;
const parts = name.split('-');
if (parts.length === 2) {
crdName = parts[0];
version = parts[1];
}
if (this.persist) {
const pluginCR = await this.$store.dispatch('management/create', {
type: UI_PLUGIN,
metadata: {
name,
namespace: UI_PLUGIN_NAMESPACE
},
spec: {
plugin: {
name: crdName,
version,
endpoint: url,
noCache: true,
metadata: {
developer: 'true',
direct: 'true'
}
}
}
});
try {
await pluginCR.save({ url: `/v1/${ UI_PLUGIN }`, method: 'POST' });
} catch (e) {
console.error('Could not create CRD for plugin', e); // eslint-disable-line no-console
btnCb(false);
}
}
this.$plugin.loadAsync(name, url).then(() => {
this.closeDialog(true);
this.$store.dispatch('growl/success', {
title: this.t('plugins.success.title', { name }),
message: this.t('plugins.success.message'),
timeout: 3000,
}, { root: true });
btnCb(true);
}).catch((error) => {
btnCb(false);
// this.closeDialog(false);
const message = typeof error === 'object' ? this.t('plugins.error.message') : error;
this.$store.dispatch('growl/error', {
title: this.t('plugins.error.title'),
message,
timeout: 5000
}, { root: true });
});
}
}
};
</script>
<template>
<modal
name="developerInstallPluginDialog"
height="auto"
:scrollable="true"
>
<div class="plugin-install-dialog">
<h4>
{{ t('plugins.developer.title') }}
</h4>
<p>
{{ t('plugins.developer.prompt') }}
</p>
<div class="custom mt-10">
<div class="fields">
<LabeledInput v-model="location" v-focus label-key="plugins.developer.fields.url" @input="updateLocation" />
</div>
</div>
<div class="custom mt-10">
<div class="fields">
<LabeledInput v-model="name" label-key="plugins.developer.fields.name" @input="updateName" />
</div>
<div class="fields mt-10">
<Checkbox v-model="persist" label-key="plugins.developer.fields.persist" />
</div>
<div class="dialog-buttons mt-20">
<button class="btn role-secondary" @click="closeDialog()">
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="load"
@click="loadPlugin"
/>
</div>
</div>
</div>
</modal>
</template>
<style lang="scss" scoped>
.plugin-install-dialog {
padding: 10px;
h4 {
font-weight: bold;
}
.dialog-panel {
display: flex;
flex-direction: column;
min-height: 100px;
p {
margin-bottom: 5px;
}
.dialog-info {
flex: 1;
}
.toggle-advanced {
display: flex;
align-items: center;
cursor: pointer;
margin: 10px 0;
&:hover {
text-decoration: none;
color: var(--link);
}
}
.version-selector {
margin: 0 10px 10px 10px;
width: auto;
}
}
.dialog-buttons {
display: flex;
justify-content: flex-end;
margin-top: 10px;
> *:not(:last-child) {
margin-right: 10px;
}
}
}
</style>

View File

@ -0,0 +1,242 @@
<script>
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { CATALOG } from '@shell/config/types';
import { CATALOG as CATALOG_ANNOTATIONS } from '@shell/config/labels-annotations';
import { UI_PLUGIN_NAMESPACE } from '@shell/config/uiplugins';
import Banner from '@components/Banner/Banner.vue';
// Note: This dialog handles installation and update of a plugin
export default {
components: {
AsyncButton,
Banner,
LabeledSelect,
},
data() {
return {
plugin: undefined,
busy: false,
version: '',
update: false,
mode: '',
};
},
computed: {
showVersionSelector() {
return this.plugin?.versions.length > 1;
},
versionOptions() {
if (!this.plugin) {
return [];
}
return this.plugin.versions.map((version) => {
return {
label: version.version,
value: version.version,
};
});
},
buttonMode() {
return this.update ? 'update' : 'install';
}
},
methods: {
showDialog(plugin, mode) {
this.plugin = plugin;
this.mode = mode;
// Default to latest version on install (this is default on the plugin)
this.version = plugin.displayVersion;
if (mode === 'update') {
// Update to latest version, so take the first version
if (plugin.versions.length > 0) {
this.version = plugin.versions[0].version;
}
} else if (mode === 'rollback') {
// Find the newest version once we remove the current version
const versionNames = plugin.versions.filter(v => v.version !== plugin.displayVersion);
if (versionNames.length > 0) {
this.version = versionNames[0].version;
}
}
// Make sure we have the version available
const versionChart = plugin.versions?.find(v => v.version === this.version);
if (!versionChart) {
this.version = plugin.versions[0].version;
}
this.busy = false;
this.update = mode !== 'install';
this.$modal.show('installPluginDialog');
},
closeDialog(result) {
this.$modal.hide('installPluginDialog');
this.$emit('closed', result);
},
async install() {
this.busy = true;
const plugin = this.plugin;
this.$emit(plugin.name, 'install');
// Find the version that the user wants to install
const version = plugin.versions?.find(v => v.version === this.version);
if (!version) {
this.busy = false;
return;
}
const repoType = version.repoType;
const repoName = version.repoName;
const repo = this.$store.getters['catalog/repo']({ repoType, repoName });
const chart = {
chartName: plugin.chart.chartName,
version: this.version,
releaseName: plugin.chart.chartName,
annotations: {
[CATALOG_ANNOTATIONS.SOURCE_REPO_TYPE]: plugin.repoType,
[CATALOG_ANNOTATIONS.SOURCE_REPO_NAME]: plugin.repoName
},
values: {}
};
const input = {
charts: [chart],
// timeout: this.cmdOptions.timeout > 0 ? `${ this.cmdOptions.timeout }s` : null,
// wait: this.cmdOptions.wait === true,
namespace: UI_PLUGIN_NAMESPACE,
};
// Helm action
const action = this.update ? 'upgrade' : 'install';
// const name = plugin.chart.chartName;
// const res = await this.repo.doAction((isUpgrade ? 'upgrade' : 'install'), input);
const res = await repo.doAction(action, input);
const operationId = `${ res.operationNamespace }/${ res.operationName }`;
// Vue.set(this.installing, this.selected.chart.chartName, operationId);
this.closeDialog(plugin);
await repo.waitForOperation(operationId);
await this.$store.dispatch(`management/find`, {
type: CATALOG.OPERATION,
id: operationId
});
}
}
};
</script>
<template>
<modal
name="installPluginDialog"
height="auto"
:scrollable="true"
>
<div v-if="plugin" class="plugin-install-dialog">
<h4 class="mt-10">
{{ t(`plugins.${ mode }.title`, { name: plugin.name }) }}
</h4>
<div class="custom mt-10">
<div class="dialog-panel">
<p>
{{ t(`plugins.${ mode }.prompt`) }}
</p>
<Banner v-if="!plugin.certified" color="warning" :label="t('plugins.install.warnNotCertified')" />
<LabeledSelect
v-if="showVersionSelector"
v-model="version"
label-key="plugins.install.version"
:options="versionOptions"
class="version-selector mt-10"
/>
<div v-else>
{{ t('plugins.install.version') }} {{ version }}
</div>
</div>
<div class="dialog-buttons">
<button :disabled="busy" class="btn role-secondary" @click="closeDialog(false)">
{{ t('generic.cancel') }}
</button>
<AsyncButton
:mode="buttonMode"
@click="install"
/>
</div>
</div>
</div>
</modal>
</template>
<style lang="scss" scoped>
.plugin-install-dialog {
padding: 10px;
h4 {
font-weight: bold;
}
.dialog-panel {
display: flex;
flex-direction: column;
min-height: 100px;
p {
margin-bottom: 5px;
}
.dialog-info {
flex: 1;
}
.toggle-advanced {
display: flex;
align-items: center;
cursor: pointer;
margin: 10px 0;
&:hover {
text-decoration: none;
color: var(--link);
}
}
.version-selector {
margin: 0 10px 10px 10px;
width: auto;
}
}
.dialog-buttons {
display: flex;
justify-content: flex-end;
margin-top: 10px;
> *:not(:last-child) {
margin-right: 10px;
}
}
}
</style>

View File

@ -0,0 +1,284 @@
<script>
import ChartReadme from '@shell/components/ChartReadme';
import { Banner } from '@components/Banner';
import LazyImage from '@shell/components/LazyImage';
export default {
components: {
Banner,
ChartReadme,
LazyImage
},
data() {
return {
showSlideIn: false,
info: undefined,
infoVersion: undefined,
versionInfo: undefined,
versionError: undefined,
defaultIcon: require('~shell/assets/images/generic-plugin.svg'),
};
},
methods: {
show(info) {
this.info = info;
this.showSlideIn = true;
this.version = null;
this.versionInfo = null;
this.versionError = null;
this.loadPluginVersionInfo();
},
hide() {
this.showSlideIn = false;
},
async loadPluginVersionInfo(version) {
this.versionError = false;
this.versionInfo = undefined;
const versionName = version || this.info.displayVersion;
this.infoVersion = versionName;
if (!this.info.chart) {
return;
}
try {
this.versionInfo = await this.$store.dispatch('catalog/getVersionInfo', {
repoType: this.info.chart.repoType,
repoName: this.info.chart.repoName,
chartName: this.info.chart.chartName,
versionName
});
// Here we set us versionInfo. The returned
// object contains everything all info
// about a currently installed app, and it has the
// following keys:
//
// - appReadme: A short overview of what the app does. This
// forms the first few paragraphs of the chart info when
// you install a Helm chart app through Rancher.
// - chart: Metadata about the Helm chart, including the
// name and version.
// - readme: This is more detailed information that appears
// under the heading "Chart Information (Helm README)" when
// you install or upgrade a Helm chart app through Rancher,
// below the app README.
// - values: All Helm chart values for the currently installed
// app.
} catch (e) {
this.versionError = true;
console.error('Unable to fetch VersionInfo: ', e); // eslint-disable-line no-console
}
}
}
};
</script>
<template>
<div class="plugin-info-panel">
<div v-if="showSlideIn" class="glass" @click="hide()" />
<div class="slideIn" :class="{'hide': false, 'slideIn__show': showSlideIn}">
<div v-if="info">
<div class="plugin-header">
<div class="plugin-icon">
<LazyImage
v-if="info.icon"
:initial-src="defaultIcon"
:error-src="defaultIcon"
:src="info.icon"
class="icon plugin-icon-img"
/>
<img
v-else
:src="defaultIcon"
class="icon plugin-icon-img"
/>
</div>
<div class="plugin-title">
<h2 class="slideIn__header">
{{ info.name }}
</h2>
<p class="plugin-description">
{{ info.description }}
</p>
</div>
<div class="plugin-close">
<div class="slideIn__header__buttons">
<div class="slideIn__header__button" @click="showSlideIn = false">
<i class="icon icon-close" />
</div>
</div>
</div>
</div>
<div>
<Banner v-if="!info.certified" color="warning" :label="t('plugins.descriptions.third-party')" class="mt-10" />
<Banner v-if="info.experimental" color="warning" :label="t('plugins.descriptions.experimental')" class="mt-10" />
</div>
<h3 v-if="info.versions">
{{ t('plugins.info.versions') }}
</h3>
<div class="plugin-versions mb-10">
<div v-for="v in info.versions" :key="v.version">
<a
class="version-link"
:class="{'version-active': v.version === infoVersion}"
@click="loadPluginVersionInfo(v.version)"
>
{{ v.version }}
</a>
</div>
</div>
<div v-if="versionError">
{{ t('plugins.info.versionError') }}
</div>
<div v-else>
<h3>
{{ t('plugins.info.detail') }}
</h3>
<ChartReadme v-if="versionInfo" :version-info="versionInfo" />
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.plugin-info-panel {
position: fixed;
top: 0;
left: 0;
$slideout-width: 35%;
$title-height: 50px;
$padding: 5px;
$slideout-width: 35%;
$header-height: 54px;
.glass {
z-index: 9;
position: fixed;
top: $header-height;
height: calc(100% - $header-height);
left: 0;
width: 100%;
opacity: 0;
}
.slideIn {
border-left: var(--header-border-size) solid var(--header-border);
position: fixed;
top: $header-height;
right: -$slideout-width;
height: calc(100% - $header-height);
background-color: var(--topmenu-bg);
width: $slideout-width;
z-index: 10;
display: flex;
flex-direction: column;
padding: 10px;
transition: right .5s ease;
h3 {
font-size: 14px;
margin: 15px 0 10px 0;
opacity: 0.7;
text-transform: uppercase;
}
.plugin-header {
border-bottom: 1px solid var(--border);
display: flex;
padding-bottom: 20px;
.plugin-title {
flex: 1;
}
}
.plugin-icon {
font-size: 40px;
margin-right:10px;
color: #888;
.plugin-icon-img {
height: 40px;
width: 40px;
}
}
.plugin-versions {
display: flex;
}
.plugin-description {
font-size: 15px;
}
.version-link {
cursor: pointer;
border: 1px solid var(--link);
padding: 2px 8px;
border-radius: 5px;
user-select: none;
margin-right: 5px;
&.version-active {
color: var(--link-text);
background: var(--link);
}
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
&__buttons {
display: flex;
}
&__button {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
> i {
font-size: 20px;
opacity: 0.5;
}
&:hover {
background-color: var(--wm-closer-hover-bg);
}
}
}
.chart-content__tabs {
display: flex;
flex-direction: column;
flex: 1;
height: 0;
padding-bottom: 10px;
::v-deep .chart-readmes {
flex: 1;
overflow: auto;
}
}
&__show {
right: 0;
}
}
}
</style>

View File

@ -0,0 +1,130 @@
<script>
import { CATALOG, UI_PLUGIN } from '@shell/config/types';
import Dialog from '@shell/components/Dialog.vue';
import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
import {
UI_PLUGIN_NAMESPACE,
UI_PLUGIN_CHARTS,
UI_PLUGINS_REPO_NAME,
UI_PLUGINS_REPO_URL,
} from '@shell/config/uiplugins';
export default {
components: {
Checkbox,
Dialog,
},
async fetch() {
if (this.$store.getters['management/schemaFor'](CATALOG.CLUSTER_REPO)) {
const repos = await this.$store.dispatch('management/findAll', { type: CATALOG.CLUSTER_REPO, opt: { force: true } });
this.defaultRepo = repos.find(r => r.name === UI_PLUGINS_REPO_NAME && r.spec.gitRepo === UI_PLUGINS_REPO_URL);
}
},
data() {
return {
errors: [],
defaultRepo: undefined,
removeRepo: false,
};
},
methods: {
async removeChart(name) {
const apps = await this.$store.dispatch('management/findAll', { type: CATALOG.APP });
const found = apps.find((app) => {
return app.namespace === UI_PLUGIN_NAMESPACE && app.name === name;
});
if (found) {
return found.remove();
}
// TODO - Return rejected promise - error
return null;
},
showDialog() {
this.removeRepo = !!this.defaultRepo;
this.$modal.show('confirm-uiplugins-remove');
},
async doRemove(btnCb) {
this.errors = [];
// Remove the charts in the reverse order that we install them in
const uninstall = [...UI_PLUGIN_CHARTS].reverse();
for (let i = 0; i < uninstall.length; i++) {
const chart = uninstall[i];
try {
await this.removeChart(chart);
} catch (e) {
this.errors.push(e.message);
}
}
if (this.removeRepo && this.defaultRepo) {
try {
await this.defaultRepo.remove();
} catch (e) {
this.errors.push(e.message);
}
}
this.$store.dispatch('management/forgetType', UI_PLUGIN);
await new Promise(resolve => setTimeout(resolve, 5000));
btnCb(true);
this.$router.push(
{
path: this.$route.path,
force: true,
},
);
},
}
};
</script>
<template>
<Dialog
name="confirm-uiplugins-remove"
:title="t('plugins.setup.remove.title')"
mode="disable"
@okay="doRemove"
>
<template>
<p>
{{ t('plugins.setup.remove.prompt') }}
</p>
<div v-if="!!defaultRepo" class="mt-20">
<Checkbox v-model="removeRepo" :primary="true" label-key="plugins.setup.remove.registry.title" />
<div class="checkbox-info">
{{ t('plugins.setup.remove.registry.prompt') }}
</div>
</div>
</template>
</Dialog>
</template>
<style lang="scss" scoped>
.enable-plugin-support {
font-size: 14px;
margin-top: 20px;
}
.plugin-setup-error {
font-size: 14px;
color: var(--error);
margin: 10px 0 0 0;
}
.checkbox-info {
margin-left: 20px;
opacity: 0.7;
}
</style>

View File

@ -0,0 +1,253 @@
<script>
import AsyncButton from '@shell/components/AsyncButton';
import IconMessage from '@shell/components/IconMessage.vue';
import { CATALOG, UI_PLUGIN } from '@shell/config/types';
import { CATALOG as CATALOG_ANNOTATIONS } from '@shell/config/labels-annotations';
import Dialog from '@shell/components/Dialog.vue';
import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
import { ASYNC_BUTTON_STATES } from '@shell/components/AsyncButton.vue';
import {
UI_PLUGIN_NAMESPACE,
UI_PLUGIN_CHARTS,
UI_PLUGIN_OPERATOR_REPO_NAME,
UI_PLUGINS_REPO_NAME,
UI_PLUGINS_REPO_URL,
UI_PLUGINS_REPO_BRANCH,
} from '@shell/config/uiplugins';
export default {
components: {
AsyncButton,
Checkbox,
IconMessage,
Dialog,
},
async fetch() {
// Check to see that the charts we need are available
const c = this.$store.getters['catalog/rawCharts'];
const charts = Object.values(c);
const found = [];
UI_PLUGIN_CHARTS.forEach((c) => {
const f = charts.find(chart => chart.repoName === UI_PLUGIN_OPERATOR_REPO_NAME & chart.chartName === c);
if (f) {
found.push(f);
}
});
this.haveCharts = (found.length === UI_PLUGIN_CHARTS.length);
if (this.haveCharts) {
this.installCharts = found;
if (this.$store.getters['management/schemaFor'](CATALOG.CLUSTER_REPO)) {
await this.$store.dispatch('management/findAll', { type: CATALOG.CLUSTER_REPO });
}
}
this.loading = false;
},
data() {
return {
loading: true,
haveCharts: false,
installCharts: [],
errors: [],
addRepo: true,
buttonState: ASYNC_BUTTON_STATES.ACTION,
};
},
computed: {
hasRancherUIPluginsRepo() {
// Look to see if the Rancher UI Plugins repository is already installed
const repos = this.$store.getters['catalog/repos'];
return !!repos.find(r => r.name === UI_PLUGINS_REPO_NAME);
}
},
methods: {
async installChart(installChart) {
const version = installChart.versions[0];
const repoType = version.repoType;
const repoName = version.repoName;
const repo = this.$store.getters['catalog/repo']({ repoType, repoName });
const chart = {
chartName: installChart.chartName,
version: version.version,
releaseName: installChart.chartName,
annotations: {
[CATALOG_ANNOTATIONS.SOURCE_REPO_TYPE]: repoType,
[CATALOG_ANNOTATIONS.SOURCE_REPO_NAME]: repoName
},
values: {}
};
const input = {
charts: [chart],
wait: true,
namespace: UI_PLUGIN_NAMESPACE,
};
const action = 'install';
const res = await repo.doAction(action, input);
const operationId = `${ res.operationNamespace }/${ res.operationName }`;
await repo.waitForOperation(operationId);
return this.$store.dispatch(`management/find`, {
type: CATALOG.OPERATION,
id: operationId
});
},
enable() {
this.errors = [];
// Reset checkbox bsed on if the repo is already installed
this.addRepo = !this.hasRancherUIPluginsRepo;
this.$modal.show('confirm-uiplugins-setup');
},
async dialogClosed(ok) {
this.errors = [];
// User wants to proceed
if (ok) {
this.buttonState = ASYNC_BUTTON_STATES.WAITING;
if (this.addRepo) {
await this.addDefaultHelmRepository();
}
await this.installPluginCharts();
await new Promise(resolve => setTimeout(resolve, 5000));
this.$store.dispatch('management/forgetType', UI_PLUGIN);
this.buttonState = this.errors.length > 0 ? ASYNC_BUTTON_STATES.ERROR : ASYNC_BUTTON_STATES.ACTION;
this.$router.push(
{
path: this.$route.path,
force: true,
},
);
}
},
async addDefaultHelmRepository() {
const name = UI_PLUGINS_REPO_NAME;
try {
const pluginCR = await this.$store.dispatch('management/create', {
type: CATALOG.CLUSTER_REPO,
metadata: { name },
spec: {
gitBranch: UI_PLUGINS_REPO_BRANCH,
gitRepo: UI_PLUGINS_REPO_URL,
}
});
return pluginCR.save();
} catch (e) {
console.error(e); // eslint-disable-line no-console
this.errors.push(e.message);
}
},
async installPluginCharts() {
for (let i = 0; i < this.installCharts.length; i++) {
const chart = this.installCharts[i];
try {
await this.installChart(chart);
} catch (e) {
this.errors.push(e.message);
}
}
return new Promise(resolve => setTimeout(resolve, 2000));
}
}
};
</script>
<template>
<div>
<IconMessage
:vertical="true"
:subtle="false"
icon="icon-gear"
>
<template v-slot:message>
<h2>
{{ t('plugins.setup.title') }}
</h2>
<div v-if="!loading">
<div v-if="!haveCharts">
<p>
{{ t('plugins.setup.prompt.cant') }}
</p>
</div>
<div v-else>
<p>
{{ t('plugins.setup.prompt.can') }}
</p>
<AsyncButton
mode="enable"
:manual="true"
:current-phase="buttonState"
class="enable-plugin-support"
@click="enable"
/>
</div>
<div v-for="(e, i) in errors" :key="i" class="plugin-setup-error">
{{ e }}
</div>
</div>
</template>
</IconMessage>
<Dialog
name="confirm-uiplugins-setup"
:title="t('plugins.setup.install.title')"
@closed="dialogClosed"
>
<template>
<p>
{{ t('plugins.setup.install.prompt') }}
</p>
<div v-if="!hasRancherUIPluginsRepo" class="mt-20">
<Checkbox v-model="addRepo" :primary="true" label-key="plugins.setup.install.addRancherRepo" />
<div class="checkbox-info">
{{ t('plugins.setup.install.airgap') }}
</div>
</div>
</template>
</Dialog>
</div>
</template>
<style lang="scss" scoped>
.enable-plugin-support {
font-size: 14px;
margin-top: 20px;
}
.plugin-setup-error {
font-size: 14px;
color: var(--error);
margin: 10px 0 0 0;
}
.checkbox-info {
margin-left: 20px;
opacity: 0.7;
}
</style>

View File

@ -0,0 +1,115 @@
<script>
import AsyncButton from '@shell/components/AsyncButton';
import { CATALOG } from '@shell/config/types';
import { UI_PLUGIN_NAMESPACE } from '@shell/config/uiplugins';
export default {
components: { AsyncButton },
data() {
return { plugin: undefined, busy: false };
},
methods: {
showDialog(plugin) {
this.plugin = plugin;
this.busy = false;
this.$modal.show('uninstallPluginDialog');
},
closeDialog(result) {
this.$modal.hide('uninstallPluginDialog');
this.$emit('closed', result);
},
async uninstall() {
this.busy = true;
const plugin = this.plugin;
// Delete the CR if this is a developer plugin (there is no Helm App, so need to remove the CRD ourselves)
if (plugin.uiplugin?.isDeveloper) {
// Delete the custom resource
await plugin.uiplugin.remove();
}
// Find the app for this plugin
const apps = await this.$store.dispatch('management/findAll', { type: CATALOG.APP });
const pluginApp = apps.find((app) => {
return app.namespace === UI_PLUGIN_NAMESPACE && app.name === plugin.name;
});
if (pluginApp) {
await pluginApp.remove();
await this.$store.dispatch('management/findAll', { type: CATALOG.OPERATION });
}
// Unload the plugin code
this.$plugin.removePlugin(plugin.name);
this.closeDialog(plugin);
}
}
};
</script>
<template>
<modal
name="uninstallPluginDialog"
height="auto"
:scrollable="true"
>
<div v-if="plugin" class="plugin-install-dialog">
<h4 class="mt-10">
{{ t('plugins.uninstall.title', { name: plugin.name }) }}
</h4>
<div class="mt-10 dialog-panel">
<div class="dialog-info">
<p>
{{ t('plugins.uninstall.prompt') }}
</p>
</div>
<div class="dialog-buttons">
<button :disabled="busy" class="btn role-secondary" @click="closeDialog(false)">
{{ t('generic.cancel') }}
</button>
<AsyncButton
mode="uninstall"
@click="uninstall()"
/>
</div>
</div>
</div>
</modal>
</template>
<style lang="scss" scoped>
.plugin-install-dialog {
padding: 10px;
h4 {
font-weight: bold;
}
.dialog-panel {
display: flex;
flex-direction: column;
min-height: 100px;
.dialog-info {
flex: 1;
}
}
.dialog-buttons {
display: flex;
justify-content: flex-end;
margin-top: 10px;
> *:not(:last-child) {
margin-right: 10px;
}
}
}
</style>

View File

@ -0,0 +1,694 @@
<script>
import Vue from 'vue';
import { mapGetters } from 'vuex';
import { mapPref, PLUGIN_DEVELOPER } from '@shell/store/prefs';
import { sortBy } from '@shell/utils/sort';
import { allHash } from '@shell/utils/promise';
import { CATALOG, UI_PLUGIN, SCHEMA } from '@shell/config/types';
import { CATALOG as CATALOG_ANNOTATIONS } from '@shell/config/labels-annotations';
import ActionMenu from '@shell/components/ActionMenu';
import Tabbed from '@shell/components/Tabbed/index.vue';
import Tab from '@shell/components/Tabbed/Tab.vue';
import IconMessage from '@shell/components/IconMessage.vue';
import LazyImage from '@shell/components/LazyImage';
import UninstallDialog from './UninstallDialog.vue';
import InstallDialog from './InstallDialog.vue';
import DeveloperInstallDialog from './DeveloperInstallDialog.vue';
import PluginInfoPanel from './PluginInfoPanel.vue';
import SetupUIPlugins from './SetupUIPlugins';
import RemoveUIPlugins from './RemoveUIPlugins';
import { isUIPlugin, uiPluginHasAnnotation, UI_PLUGIN_NAMESPACE } from '@shell/config/uiplugins';
export default {
components: {
ActionMenu,
DeveloperInstallDialog,
IconMessage,
InstallDialog,
LazyImage,
PluginInfoPanel,
Tab,
Tabbed,
UninstallDialog,
SetupUIPlugins,
RemoveUIPlugins,
},
data() {
return {
view: '',
charts: [],
installing: {},
errors: {},
plugins: [], // The installed plugins
helmOps: [], // Helm operations
loading: true,
menuTargetElement: null,
menuTargetEvent: null,
menuOpen: false,
defaultIcon: require('~shell/assets/images/generic-plugin.svg'),
};
},
layout: 'plain',
async fetch() {
const hash = {};
if (this.hasPluginCRD) {
hash.plugins = this.$store.dispatch('management/findAll', { type: UI_PLUGIN });
}
hash.load = await this.$store.dispatch('catalog/load');
if (this.$store.getters['management/schemaFor'](CATALOG.OPERATION)) {
hash.helmOps = await this.$store.dispatch('management/findAll', { type: CATALOG.OPERATION });
}
const res = await allHash(hash);
this.plugins = res.plugins || [];
this.helmOps = res.helmOps || [];
const c = this.$store.getters['catalog/rawCharts'];
this.charts = Object.values(c);
this.loading = false;
},
computed: {
pluginDeveloper: mapPref(PLUGIN_DEVELOPER),
...mapGetters({ uiplugins: 'uiplugins/plugins' }),
...mapGetters({ uiErrors: 'uiplugins/errors' }),
menuActions() {
const menuActions = [];
// Only show Developer Load action if the user has this enabled in preferences
if (this.pluginDeveloper) {
menuActions.push({
action: 'devLoad',
label: this.t('plugins.developer.label'),
enabled: true
});
menuActions.push( { divider: true });
}
if (this.hasPluginCRD) {
menuActions.push({
action: 'removePluginSupport',
label: this.t('plugins.setup.remove.label'),
enabled: true
});
}
return menuActions;
},
// Is the Plugin CRD available ?
hasPluginCRD() {
const schemas = this.$store.getters[`management/all`](SCHEMA);
const crd = schemas.find(s => s.id === UI_PLUGIN);
return !!crd;
},
list() {
const all = this.available;
switch (this.view) {
case 'installed':
return all.filter(p => !!p.installed || !!p.installing);
case 'updates':
return this.updates;
case 'available':
return all.filter(p => !p.installed);
default:
return all;
}
},
hasMenuActions() {
return this.menuActions?.length > 0;
},
// Message to display when the tab view is empty (depends on the tab)
emptyMessage() {
return this.t(`plugins.empty.${ this.view }`);
},
updates() {
return this.available.filter(plugin => !!plugin.upgrade);
},
available() {
let all = this.charts.filter(c => isUIPlugin(c));
// Filter out hidden charts
all = all.filter(c => !uiPluginHasAnnotation(c, CATALOG_ANNOTATIONS.HIDDEN, 'true'));
all = all.map((chart) => {
const item = {
name: chart.chartNameDisplay,
description: chart.chartDescription,
id: chart.id,
versions: [],
displayVersion: chart.versions?.length > 0 ? chart.versions[0].version : '',
installed: false,
builtin: false,
experimental: uiPluginHasAnnotation(chart, CATALOG_ANNOTATIONS.EXPERIMENTAL, 'true'),
certified: uiPluginHasAnnotation(chart, CATALOG_ANNOTATIONS.CERTIFIED, CATALOG_ANNOTATIONS._RANCHER),
};
this.latest = chart.versions[0];
item.versions = [...chart.versions];
item.chart = chart;
if (this.latest) {
item.icon = chart.icon || this.latest.annotations['catalog.cattle.io/ui-icon'];
}
if (this.installing[item.name]) {
item.installing = this.installing[item.name];
}
return item;
});
// Check that all of the loaded plugins are represented
this.uiplugins.forEach((p) => {
const chart = all.find(c => c.name === p.name);
if (!chart) {
// A pluign is loaded, but there is no chart, so add an item so that it shows up
const item = {
name: p.name,
description: p.metadata?.description,
id: p.id,
versions: [],
displayVersion: p.metadata?.version || '-',
installed: true,
builtin: !!p.builtin,
};
all.push(item);
}
});
// Go through the CRs for the plugins and wire them into the catalog
this.plugins.forEach((p) => {
if (!p.removed) {
const chart = all.find(c => c.name === p.name);
if (chart) {
chart.installed = true;
chart.uiplugin = p;
chart.displayVersion = p.version;
// Can't do this here
chart.installing = this.installing[chart.name];
// Check for upgrade
if (chart.versions.length && p.version !== chart.versions[0].version) {
chart.upgrade = chart.versions[0].version;
}
}
}
});
// Merge in the plugin load errors
Object.keys(this.uiErrors).forEach((e) => {
const chart = all.find(c => c.name === e);
if (chart) {
chart.error = !!this.uiErrors[e];
}
});
// Merge in the plugin load errors from help ops
Object.keys(this.errors).forEach((e) => {
const chart = all.find(c => c.name === e);
if (chart) {
chart.helmError = !!this.errors[e];
}
});
// Sort by name
return sortBy(all, 'name', false);
},
},
watch: {
helmOps(neu) {
// Get Helm operations for UI plugins and order by date
let pluginOps = neu.filter((op) => {
return op.namespace === UI_PLUGIN_NAMESPACE;
});
pluginOps = sortBy(pluginOps, 'metadata.creationTimestamp', true);
// Go through the installed plugins
(this.available || []).forEach((plugin) => {
const op = pluginOps.find(o => o.status?.releaseName === plugin.name);
if (op) {
const active = op.metadata.state?.transitioning;
const error = op.metadata.state?.error;
Vue.set(this.errors, plugin.name, error);
if (active) {
this.updatePluginInstallStatus(plugin.name, op.status.action);
} else if (op.status.action === 'uninstall') {
// Uninstall has finished
this.updatePluginInstallStatus(plugin.name, false);
} else if (error) {
this.updatePluginInstallStatus(plugin.name, false);
}
} else {
this.updatePluginInstallStatus(plugin.name, false);
}
});
},
plugins(neu) {
const installed = this.$store.getters['uiplugins/plugins'];
neu.forEach((plugin) => {
const existing = installed.find(p => !p.removed && p.name === plugin.name);
if (!existing && plugin.isCached) {
this.$plugin.loadAsyncByNameAndVersion(plugin.name, plugin.version).catch((e) => {
console.error(`Failed to load plugin ${ plugin.name } (${ plugin.version })`); // eslint-disable-line no-console
});
this.updatePluginInstallStatus(plugin.name, false);
}
});
},
},
// Forget the types when we leave the page
beforeDestroy() {
this.$store.dispatch('cluster/forgetType', UI_PLUGIN);
this.$store.dispatch('cluster/forgetType', CATALOG.OPERATION);
this.$store.dispatch('cluster/forgetType', CATALOG.APP);
this.$store.dispatch('cluster/forgetType', CATALOG.CLUSTER_REPO);
},
methods: {
filterChanged(f) {
this.view = f.selectedName;
},
removePluginSupport() {
this.$refs.removeUIPlugins.showDialog();
},
// Developer Load is in the action menu
showDeveloperLoaddDialog() {
this.$refs.developerInstallDialog.showDialog();
},
showInstallDialog(plugin, mode, ev) {
ev.target?.blur();
ev.preventDefault();
ev.stopPropagation();
this.$refs.installDialog.showDialog(plugin, mode);
},
showUninstallDialog(plugin, ev) {
ev.target?.blur();
ev.preventDefault();
ev.stopPropagation();
this.$refs.uninstallDialog.showDialog(plugin);
},
didUninstall(plugin) {
if (plugin) {
this.updatePluginInstallStatus(plugin.name, 'uninstall');
// Clear the load error, if there was one
this.$store.dispatch('uiplugins/setError', { name: plugin.name, error: false });
}
},
didInstall(plugin) {
if (plugin) {
// Change the view to installed if we started installing a plugin
this.$refs.tabs?.select('installed');
// Clear the load error, if there was one previously
this.$store.dispatch('uiplugins/setError', { name: plugin.name, error: false });
}
},
showPluginDetail(plugin) {
this.$refs.infoPanel.show(plugin);
},
updatePluginInstallStatus(name, status) {
// console.log(`UPDATING PLUGIN STATUS: ${ name } ${ status }`);
Vue.set(this.installing, name, status);
},
setMenu(event) {
this.menuOpen = !!event;
if (event) {
this.menuTargetElement = this.$refs.actions;
this.menuTargetEvent = event;
} else {
this.menuTargetElement = undefined;
this.menuTargetEvent = undefined;
}
}
}
};
</script>
<template>
<div class="plugins">
<div class="plugin-header">
<h2>{{ t('plugins.title') }}</h2>
<button
v-if="hasPluginCRD && hasMenuActions"
ref="actions"
aria-haspopup="true"
type="button"
class="btn actions"
@click="setMenu"
>
<i class="icon icon-actions" />
</button>
<ActionMenu
v-if="hasPluginCRD && hasMenuActions"
:custom-actions="menuActions"
:open="menuOpen"
:use-custom-target-element="true"
:custom-target-element="menuTargetElement"
:custom-target-event="menuTargetEvent"
@close="setMenu(false)"
@devLoad="showDeveloperLoaddDialog"
@removePluginSupport="removePluginSupport"
/>
</div>
<PluginInfoPanel ref="infoPanel" />
<div v-if="!hasPluginCRD">
<div v-if="loading" class="data-loading">
<i class="icon-spin icon icon-spinner" />
<t k="generic.loading" :raw="true" />
</div>
<SetupUIPlugins v-else class="setup-message" />
</div>
<div v-else>
<Tabbed ref="tabs" :tabs-only="true" @changed="filterChanged">
<Tab name="installed" label-key="plugins.tabs.installed" :weight="20" />
<Tab name="available" label-key="plugins.tabs.available" :weight="19" />
<Tab name="updates" label-key="plugins.tabs.updates" :weight="18" :badge="updates.length" />
<Tab name="all" label-key="plugins.tabs.all" :weight="17" />
</Tabbed>
<div v-if="loading" class="data-loading">
<i class="icon-spin icon icon-spinner" />
<t k="generic.loading" :raw="true" />
</div>
<div v-else class="plugin-list" :class="{'v-margin': !list.length}">
<IconMessage
v-if="list.length === 0"
:vertical="true"
:subtle="true"
icon="icon-gear"
:message="emptyMessage"
/>
<template v-else>
<div v-for="plugin in list" :key="plugin.name" class="plugin" @click="showPluginDetail(plugin)">
<div class="plugin-icon">
<LazyImage
v-if="plugin.icon"
:initial-src="defaultIcon"
:error-src="defaultIcon"
:src="plugin.icon"
class="icon plugin-icon-img"
/>
<img
v-else
:src="defaultIcon"
class="icon plugin-icon-img"
/>
</div>
<div class="plugin-metadata">
<div class="plugin-name">
{{ plugin.name }}
</div>
<div>{{ plugin.description }}</div>
<div v-if="plugin.builtin" class="plugin-builtin">
{{ t('plugins.labels.builtin') }}
</div>
<div class="plugin-version">
<div v-if="plugin.installing" class="plugin-installing">
<i class="version-busy icon icon-spin icon-spinner" />
<div v-if="plugin.installing='install'">
{{ t('plugins.labels.installing') }}
</div>
<div v-else>
{{ t('plugins.labels.uninstalling') }}
</div>
</div>
<span v-else>
<span>{{ plugin.displayVersion }}</span>
<span v-if="plugin.upgrade" v-tooltip="t('plugins.upgradeAvailable')"> -> {{ plugin.upgrade }}</span>
</span>
</div>
<div class="plugin-badges">
<div v-if="!plugin.certified" v-tooltip="t('plugins.descriptions.third-party')">
{{ t('plugins.labels.third-party') }}
</div>
<div v-if="plugin.experimental" v-tooltip="t('plugins.descriptions.experimental')">
{{ t('plugins.labels.experimental') }}
</div>
</div>
<div class="plugin-spacer" />
<div class="plugin-actions">
<div v-if="plugin.error" v-tooltip="t('plugins.loadError')" class="plugin-error">
<i class="icon icon-warning" />
</div>
<div v-if="plugin.helmError" v-tooltip="t('plugins.helmError')" class="plugin-error">
<i class="icon icon-warning" />
</div>
<div class="plugin-spacer" />
<div v-if="plugin.installing">
<!-- Don't show any buttons -->
</div>
<div v-else-if="plugin.installed" class="plugin-buttons">
<button v-if="!plugin.builtin" class="btn role-secondary" @click="showUninstallDialog(plugin, $event)">
{{ t('plugins.uninstall.label') }}
</button>
<button v-if="plugin.upgrade" class="btn role-secondary" @click="showInstallDialog(plugin, 'update', $event)">
{{ t('plugins.update.label') }}
</button>
<button v-if="!plugin.upgrade && plugin.versions.length > 1" class="btn role-secondary" @click="showInstallDialog(plugin, 'rollback', $event)">
{{ t('plugins.rollback.label') }}
</button>
</div>
<div v-else class="plugin-buttons">
<button class="btn role-secondary" @click="showInstallDialog(plugin, 'install', $event)">
{{ t('plugins.install.label') }}
</button>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<InstallDialog ref="installDialog" @closed="didInstall" @update="updatePluginInstallStatus" />
<UninstallDialog ref="uninstallDialog" @closed="didUninstall" @update="updatePluginInstallStatus" />
<DeveloperInstallDialog ref="developerInstallDialog" @closed="didInstall" />
<RemoveUIPlugins ref="removeUIPlugins" />
</div>
</template>
<style lang="scss" scoped>
.setup-message {
margin-top: 100px;
}
.data-loading {
align-items: center;
display: flex;
justify-content: center;
> I {
margin-right: 5px;
}
}
.plugin-list {
display: flex;
flex-wrap: wrap;
> .plugin:not(:last-child) {
margin-right: 20px;
}
&.v-margin {
margin-top: 40px;
}
}
.plugins {
display: inherit;
}
.plugin-header {
display: flex;
align-items: center;
margin-bottom: 10px;
> h2 {
flex: 1;
margin-bottom: 0;
}
}
.plugin {
display: flex;
border: 1px solid var(--border);
padding: 10px;
width: calc(33% - 20px);
max-width: 540px;
margin-bottom: 20px;
cursor: pointer;
.plugin-icon {
font-size: 40px;
margin-right:10px;
color: #888;
.plugin-icon-img {
height: 40px;
width: 40px;
}
}
.plugin-spacer {
flex: 1;
}
.plugin-metadata {
display: flex;
flex: 1;
flex-direction: column;
.plugin-buttons {
> button:not(:first-child) {
margin-left: 5px;
}
}
}
.plugin-builtin {
color: var(--primary);
display: block;
padding: 2px 0;
text-transform: uppercase;
}
.plugin-name {
font-size: 16px;
font-weight: bold;
margin-bottom: 5px;
}
.plugin-badges {
display: flex;
> div {
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 8px;
margin-right: 10px;
font-size: 12px;
}
}
.plugin-version {
align-items: center;
display: inline-flex;
font-size: 12px;
border-radius: 4px;
margin: 5px 0;
i.icon-spinner {
padding-right: 5px;
font-size: 16px;
height: 16px;
width: 16px;
}
.plugin-installing {
align-items: center;
display: flex;
> div {
font-size: 14px;
margin-left: 5px;
}
}
}
.plugin-actions {
align-items:center;
display: flex;
$error-icon-size: 22px;
.plugin-error {
display: inline;
cursor: help;
> i {
color: var(--error);
height: $error-icon-size;
font-size: $error-icon-size;
width: $error-icon-size;
}
}
.btn {
line-height: 20px;
min-height: 20px;
padding: 0 5px;
}
}
}
@media screen and (max-width: 1200px) {
.plugin-list {
.plugin {
width: calc(50% - 20px);
}
}
}
@media screen and (max-width: 960px) {
.plugin-list {
.plugin {
margin-right: 0 !important;
width: 100%;
}
}
}
</style>

View File

@ -1,387 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { LabeledInput } from '@components/Form/LabeledInput';
export default {
components: { LabeledInput },
data() {
return {
name: '',
location: '',
catalogUrl: '',
view: 'installed',
dialogOpen: false,
catalogDialogOpen: false,
canModifyName: true,
canModifyLocation: true,
};
},
layout: 'plain',
async fetch() {
await this.$store.dispatch('uiplugins/loadCatalogs');
},
computed: {
...mapGetters({ plugins: 'uiplugins/plugins' }),
...mapGetters({ catalog: 'uiplugins/catalog' }),
list() {
// Get installed plugins and mark up the list
const pMap = {};
const list = [];
// Map of plugins in the catalog
const cMap = {};
this.plugins.forEach((p) => {
pMap[p.name] = p;
});
this.catalog.forEach((catalogItem) => {
const item = { ...catalogItem };
list.push(item);
cMap[item.name] = true;
item.installed = pMap[item.name]?.metadata;
item.builtin = !!pMap[item.name]?.builtin;
item.displayVersion = item.installed ? item.installed.version : item.version;
item.icon = item.installed?.icon;
item.download = `/download-pkg/${ item.name }-${ item.version }`;
if (item.installed) {
if (item.installed.version !== item.version) {
// TODO: Only if newer
item.upgrade = item.version;
}
}
});
this.plugins.forEach((p) => {
if (!cMap[p.name]) {
// Plugin is installed but not in the catalog, so add item for it
list.push({
...p.metadata,
installed: true,
builtin: p.builtin,
displayVersion: p.metadata?.version,
icon: p.metadata?.icon
});
}
});
return list;
}
},
watch: {
name(neu, old) {
if (this.canModifyLocation) {
this.location = `/pkg/${ neu }/${ neu }.umd.min.js`;
}
},
location(neu, old) {
if (this.canModifyName) {
const names = neu.split('/');
let last = names[names.length - 1];
const index = last.indexOf('.umd.min.js');
if (index !== -1) {
last = last.substr(0, index);
}
this.name = last;
}
}
},
methods: {
updateName(v) {
this.canModifyName = v.length === 0;
},
updateLocation(v) {
this.canModifyLocation = v.length === 0;
},
showAddDialog() {
this.dialogOpen = true;
this.$modal.show('addPluginDialog');
},
closeAddDialog() {
this.dialogOpen = false;
this.$modal.hide('addPluginDialog');
},
showAddCatalogDialog() {
this.catalogDialogOpen = true;
this.$modal.show('addCatlogDialog');
},
closeAddCatalogDialog() {
this.catalogDialogOpen = false;
this.$modal.hide('addCatlogDialog');
},
addCatalog() {
this.$store.dispatch('uiplugins/addCatalog', this.catalogUrl);
this.$store.dispatch('uiplugins/loadCatalogs');
this.closeAddCatalogDialog();
},
loadPlugin() {
this.load(this.name, this.location);
},
load(name, url) {
if (!name) {
const parts = url.split('/');
const n = parts[parts.length - 1];
name = n.split('.')[0];
}
this.$plugin.loadAsync(name, url).then(() => {
this.closeAddDialog();
this.$store.dispatch('growl/success', {
title: `Loaded plugin ${ name }`,
message: `Plugin was loaded successfully`,
timeout: 3000,
}, { root: true });
}).catch((error) => {
this.closeAddDialog();
const message = typeof error === 'object' ? 'Could not load code' : error;
this.$store.dispatch('growl/error', {
title: 'Error loading plugin',
message,
timeout: 5000
}, { root: true });
});
},
async install(plugin) {
// Might need to download the package first
if (plugin.download) {
await this.$store.dispatch('rancher/request', { url: plugin.download }, { root: true });
}
const name = `${ plugin.name }-${ plugin.version }`;
let moduleUrl = `/pkg/${ name }/${ name }.umd.min.js`;
if (plugin.location) {
moduleUrl = `${ plugin.location }/${ name }/${ name }.umd.min.js`;
}
this.load(name, moduleUrl);
},
uninstall(plugin) {
this.$plugin.removePlugin(plugin.name);
}
}
};
</script>
<template>
<div class="plugins">
<div class="plugin-header">
<h2>Plugins</h2>
<button class="btn role-primary mr-10" @click="showAddCatalogDialog()">
Add Catalog
</button>
<button class="btn role-primary" @click="showAddDialog()">
Load
</button>
</div>
<br />
<div class="plugin-list">
<div v-for="plugin in list" :key="plugin.name" class="plugin">
<div class="plugin-icon">
<img v-if="plugin.icon" :src="plugin.icon" class="icon plugin-icon-img" />
<i v-else class="icon icon-apps"></i>
</div>
<div class="plugin-metadata">
<div class="plugin-name">
{{ plugin.name }}
</div>
<div>{{ plugin.description }}</div>
<div v-if="plugin.builtin" class="plugin-builtin">
Built-in plugin
</div>
<div class="plugin-version">
{{ plugin.displayVersion }}
</div>
<div v-if="plugin.installed">
<!-- <div>{{ plugin.installed.version }}</div> -->
<div class="plugin-actions">
<button class="btn role-secondary" @click="uninstall(plugin)">
Uninstall
</button>
</div>
</div>
<div v-else class="plugin-actions">
<button class="btn role-secondary" @click="install(plugin)">
Install
</button>
</div>
</div>
</div>
</div>
<modal
name="addPluginDialog"
height="auto"
:scrollable="true"
@closed="closeAddDialog()"
>
<div class="plugin-add-dialog">
<h4 class="mt-20">
Load Plugin
</h4>
<div class="custom">
<div class="fields">
<LabeledInput v-model="name" v-focus label="Plugin module name" @input="updateName" />
</div>
</div>
<div class="custom mt-10">
<div class="fields">
<LabeledInput v-model="location" label="Plugin URL" @input="updateLocation" />
</div>
<div class="dialog-buttons">
<button class="btn role-secondary" @click="closeAddDialog()">
Cancel
</button>
<button class="btn role-primary" @click="loadPlugin()">
Load Plugin
</button>
</div>
</div>
</div>
</modal>
<modal
name="addCatlogDialog"
height="auto"
:scrollable="true"
@closed="closeAddCatalogDialog()"
>
<div class="plugin-add-dialog">
<h4 class="mt-20">
Add Plugin Catalog
</h4>
<div class="custom mt-10">
<div class="fields">
<LabeledInput v-model="catalogUrl" label="Catalog URL" />
</div>
<div class="dialog-buttons">
<button class="btn role-secondary" @click="closeAddCatalogDialog()">
Cancel
</button>
<button class="btn role-primary" @click="addCatalog()">
Add
</button>
</div>
</div>
</div>
</modal>
</div>
</template>
<style lang="scss" scoped>
.plugin-list {
display: flex;
> .plugin:not(:last-child) {
margin-right: 20px;
}
}
.plugins {
display: inherit;
}
.plugin-add-dialog {
padding: 10px;
.dialog-buttons {
display: flex;
justify-content: flex-end;
> *:not(:last-child) {
margin-right: 10px;
}
}
}
.plugin-header {
display: flex;
align-items: center;
margin-bottom: 10px;
> h2 {
flex: 1;
margin-bottom: 0;
}
}
.plugin {
display: flex;
border: 1px solid var(--border);
padding: 10px;
width: 320px;
.plugin-icon {
font-size: 40px;
margin-right:10px;
color: #888;
.plugin-icon-img {
height: 40px;
width: 40px;
}
}
.plugin-metadata {
flex: 1;
}
.plugin-builtin {
color: var(--primary);
display: block;
padding: 2px 0;
text-transform: uppercase;
}
.plugin-name {
font-size: 16px;
font-weight: bold;
}
.plugin-version {
border: 1px solid var(--primary);
display: inline-block;
font-size: 12px;
padding: 2px 5px;
border-radius: 4px;
margin: 5px 0;
}
.plugin-actions {
display: flex;
justify-content: flex-end;
.btn {
line-height: 20px;
min-height: 20px;
padding: 0 5px;
}
}
}
.fields {
display: flex;
// > div:first-child {
// width: 260px;
// margin-right: 20px;
// }
}
.custom {
button {
margin-top: 10px;
}
}
</style>

View File

@ -1,6 +1,7 @@
<script>
import day from 'dayjs';
import { mapGetters } from 'vuex';
import { isAdminUser } from '@shell/store/type-map';
import BackLink from '@shell/components/BackLink';
import BackRoute from '@shell/mixins/back-link';
import ButtonGroup from '@shell/components/ButtonGroup';
@ -8,7 +9,7 @@ import { Checkbox } from '@components/Form/Checkbox';
import LandingPagePreference from '@shell/components/LandingPagePreference';
import {
mapPref, THEME, KEYMAP, DATE_FORMAT, TIME_FORMAT, ROWS_PER_PAGE, HIDE_DESC, SHOW_PRE_RELEASE, MENU_MAX_CLUSTERS,
VIEW_IN_API, ALL_NAMESPACES, THEME_SHORTCUT
VIEW_IN_API, ALL_NAMESPACES, THEME_SHORTCUT, PLUGIN_DEVELOPER
} from '@shell/store/prefs';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { addObject } from '@shell/utils/array';
@ -19,8 +20,11 @@ export default {
components: {
BackLink, ButtonGroup, LabeledSelect, Checkbox, LandingPagePreference, LocaleSelector
},
mixins: [BackRoute],
computed: {
mixins: [BackRoute],
data() {
return { admin: isAdminUser(this.$store.getters) };
},
computed: {
keymap: mapPref(KEYMAP),
viewInApi: mapPref(VIEW_IN_API),
allNamespaces: mapPref(ALL_NAMESPACES),
@ -31,6 +35,7 @@ export default {
hideDesc: mapPref(HIDE_DESC),
showPreRelease: mapPref(SHOW_PRE_RELEASE),
menuMaxClusters: mapPref(MENU_MAX_CLUSTERS),
pluginDeveloper: mapPref(PLUGIN_DEVELOPER),
...mapGetters(['isSingleProduct']),
@ -231,6 +236,10 @@ export default {
<Checkbox v-model="themeShortcut" :label="t('prefs.advFeatures.themeShortcut', {}, true)" class="mt-20" />
<br />
<Checkbox v-if="!isSingleProduct" v-model="hideDescriptions" :label="t('prefs.hideDesc.label')" class="mt-20" />
<template v-if="admin">
<br />
<Checkbox v-model="pluginDeveloper" :label="t('prefs.advFeatures.pluginDeveloper', {}, true)" class="mt-20" />
</template>
</div>
<!-- YAML editor key mapping -->
<div class="col mt-10 mb-10">

17
shell/pages/safeMode.vue Normal file
View File

@ -0,0 +1,17 @@
<script>
export default {
middleware({ redirect, store } ) {
const dashboardHome = { name: 'home' };
const t = store.getters['i18n/t'];
setTimeout(() => {
store.dispatch('growl/success', {
title: t('plugins.safeMode.title'),
message: t('plugins.safeMode.message')
}, { root: true });
}, 1000);
return redirect(dashboardHome);
}
};
</script>

View File

@ -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 });
}
});
}
};

View File

@ -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();
}
}

View File

@ -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) ) {

View File

@ -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';

View File

@ -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'];

View File

@ -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) {