mirror of https://github.com/rancher/dashboard.git
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:
parent
bd1a8abd0a
commit
80eeacfb41
|
|
@ -142,6 +142,7 @@ jobs:
|
|||
run: |
|
||||
ls
|
||||
yarn coverage
|
||||
ls coverage
|
||||
|
||||
- name: Upload unit test coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ export default {
|
|||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
badge: {
|
||||
default: 0,
|
||||
required: false,
|
||||
type: Number
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue