HARVESTER: feat: setting & sshKey page

This commit is contained in:
wujun 2021-07-13 10:42:10 +08:00
parent 9c72e6c376
commit 87d104d27b
22 changed files with 1648 additions and 9 deletions

View File

@ -4041,6 +4041,8 @@ tableHeaders:
value: Value
version: Version
weight: Weight
progress: Progress
fingerprint: Fingerprint
target:
router:
@ -4656,6 +4658,42 @@ workload:
tip: The duration the pod needs to terminate successfully.
title: Upgrading
harvester:
dashboard:
label: Dashboard
host:
label: Hosts
virtualMachine:
label: Virtual Machines
volume:
label: Volumes
image:
label: Images
vmTemplate:
label: Templates
backup:
label: Backups
network:
label: Networks
sshKey:
label: SSH Keys
keypair: SSH Key
tabs:
basics: Basics
setting:
label: Settings
validation:
physicalNIC: DefaultPhysicalNIC
placeholder:
accessKeyId: specify your access key id
secretAccessKey: specify your secret access key
cert: upload a self-signed SSL certificate
vlanChangeTip: The newly modified default physical NIC only applies to newly added nodes, not existing ones
defaultPhysicalNIC: Default Physical NIC
cloudTemplate:
label: Cloud Config Templates
##############################
# Model Properties
@ -4951,6 +4989,62 @@ typeLabel:
one { Workload }
other { Workloads }
}
kubevirt.io.virtualmachine: |-
{count, plural,
one { Virtual Machine }
other { Virtual Machines }
}
harvesterhci.io.virtualmachineimage: |-
{count, plural,
one { Image }
other { Images }
}
harvesterhci.io.keypair: |-
{count, plural,
one { SSH Key }
other { SSH Keys }
}
host: |-
{count, plural,
one { Host }
other { Hosts }
}
configmap: |-
{count, plural,
one { Cloud Config Template }
other { Cloud Config Templates }
}
k8s.cni.cncf.io.networkattachmentdefinition: |-
{count, plural,
one { Network }
other { Networks }
}
cdi.kubevirt.io.datavolume: |-
{count, plural,
one { Volume }
other { Volumes }
}
harvesterhci.io.user: |-
{count, plural,
one { User }
other { Users }
}
harvesterhci.io.setting: |-
{count, plural,
one { Setting }
other { Settings }
}
harvesterhci.io.virtualmachinetemplateversion: |-
{count, plural,
one { Template }
other { Templates }
}
harvesterhci.io.virtualmachinebackup: |-
{count, plural,
one { Backup }
other { Backups }
}
action:
clone: Clone
@ -5043,6 +5137,14 @@ advancedSettings:
'ui-banners': 'Classification banner is used to display a custom fixed banner in the header, footer, or both.'
'ui-default-landing': 'The default page users land on after login.'
'brand': Folder name for an alternative theme defined in '/assets/brand'
'vlan': Default physical NIC name of the VLAN network.
'api-ui-source': Config how to load the API and UI source.
'backup-target': Custom back target to store VM backups.
'log-level': Configure Harvester server log level. Default to info.
'rancher-enabled': Specify whether to display the Rancher UI navigation button or not.
'server-version': Harvester server version.
'upgrade-checker-enabled': Specify whether to enable Harvester upgrade check or not. Default is true.
'upgrade-checker-url': Default Harvester upgrade check url. Only used when the <code>upgrade-checker-enabled</code> is equal to true.
editHelp:
'ui-banners': This setting takes a JSON object containing 3 root parameters; <code>banner</code>, <code>showHeader</code>, <code>showFooter</code>. <code>banner</code> is an object containing; <code>textColor</code>, <code>background</code>, and <code>text</code>, where <code>textColor</code> and <code>background</code> are any valid CSS color value.
enum:
@ -5057,6 +5159,14 @@ advancedSettings:
dynamic: Dynamic
true: Local
false: Remote
'api-ui-source':
auto: 'Auto'
bundled: 'Bundled'
external: 'External'
'log-level':
info: Info
debug: Debug
trace: Trace
featureFlags:
label: Feature Flags

View File

@ -3142,6 +3142,8 @@ tableHeaders:
value:
version: 版本号
weight: 权重
progress: 进度
fingerprint: 唯一标识
target:
router:
@ -3743,6 +3745,42 @@ workload:
tip: 杀死 Pod 前所需的等待时间
title: 升级中
harvester:
dashboard:
label: 概览
host:
label: 主机
virtualMachine:
label: 虚拟机
volume:
label:
image:
label: 镜像
vmTemplate:
label: 虚拟机模板
backup:
label: 备份
network:
label: 网络
sshKey:
label: SSH 秘钥
keypair: SSH 公钥
tabs:
basics: 基本信息
setting:
label: 设置
validation:
physicalNIC: DefaultPhysicalNIC
placeholder:
accessKeyId: 指定您的访问密钥ID
secretAccessKey: 指定您的私密访问密钥
cert: 上传自签名SSL证书
vlanChangeTip: 新修改的默认物理网卡仅适用于新增节点,不适用于现有节点
defaultPhysicalNIC: 默认物理网卡
cloudTemplate:
label: Cloud 配置模板
##############################
# Model Properties
@ -4001,6 +4039,17 @@ typeLabel:
one { API密钥 }
other { API密钥 }
}
kubevirt.io.virtualmachine: 虚拟机
harvesterhci.io.virtualmachineimage: 镜像
harvesterhci.io.keypair: SSH 密钥
cloudTemplate: Cloud 配置模板
harvesterhci.io.virtualmachinetemplateversion: 虚拟机模板
k8s.cni.cncf.io.networkattachmentdefinition: 网络
cdi.kubevirt.io.datavolume:
harvesterhci.io.user: 用户
harvesterhci.io.setting: 设置
host: 主机
harvesterhci.io.virtualmachinebackup: 备份
action:
clone: 克隆
@ -4087,9 +4136,25 @@ advancedSettings:
'rke-metadata-config': '配置RKE元数据刷新参数。'
'ui-banners': '分类横幅是用来在页眉、页脚或两者中显示一个自定义的固定横幅。'
'ui-default-landing': '用户在登录后登陆的默认页面。'
'vlan': VLAN网络的默认物理NIC名称.
'api-ui-source': 配置如何加载API和UI资源.
'backup-target': 自定义备份目标用于存储VM备份.
'log-level': 配置Harvester服务器日志级别. 默认为信息
'rancher-enabled': 指定是否显示Rancher UI导航按钮.
'server-version': Harvester服务器版本.
'upgrade-checker-enabled': 指定是否启用Harvester升级检查. 默认为true.
'upgrade-checker-url': 默认的Harvester升级检查地址。 仅在<code> upgrade-checker-enabled </code> 等于true时使用。
editHelp:
'ui-banners': 这个设置需要一个JSON对象包含3个根参数<code>banner</code>, <code>showHeader</code>, <code>showFooter</code>。<code>banner</code>是一个包含;<code>textColor</code>, <code>background</code>, 和<code>text</code>的对象,其中<code>textColor</code>和<code>background</code>是任何有效的CSS颜色值。
#enum:
enum:
'api-ui-source':
auto: 'Auto'
bundled: 'Bundled'
external: 'External'
'log-level':
info: Info
debug: Debug
trace: Trace
#'ui-default-landing':
# ember: Cluster Manager
#vue: Cluster Explorer

42
components/LabelValue.vue Normal file
View File

@ -0,0 +1,42 @@
<script>
export default {
props: {
name: {
type: String,
required: true
},
value: {
type: [Number, String, undefined],
default: ''
}
}
};
</script>
<template>
<div class="label">
<div class="text-label">
<slot name="name">
{{ name }}
</slot>
</div>
<div class="value">
<slot name="value">
{{ value }}
</slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.label {
display: flex;
flex-direction: column;
.value {
font-size: 14px;
line-height: 18px;
}
}
</style>

38
components/Tip.vue Normal file
View File

@ -0,0 +1,38 @@
<script>
export default {
props: {
icon: {
type: String,
default: ''
},
text: {
type: String,
default: ''
}
}
};
</script>
<template>
<div class="tip">
<span class="my-icon" :class="icon"></span>
<span class="text">{{ text }}</span>
</div>
</template>
<style lang="scss" scoped>
.tip {
color: var(--disabled-text);
display: flex;
align-items: center;
}
.my-icon {
font-size: 16px;
margin-right: 2px;
}
.text {
font-size: 14px;
}
</style>

View File

@ -79,6 +79,28 @@ export default {
return sorted;
},
harvesters() {
const all = this.$store.getters['management/all'](MANAGEMENT.CLUSTER);
let out = all.map((x) => {
return {
id: x.id,
label: x.nameDisplay,
ready: x.isReady,
osLogo: x.providerOsLogo,
logo: x.providerLogo,
isLocal: x.isLocal
};
});
if (this.clusterFilter.length > 0) {
out = out.filter(item => item.label.indexOf(this.clusterFilter) === 0);
}
const sorted = sortBy(out, ['ready:desc', 'label']);
return sorted;
},
dev: mapPref(DEV),
maxClustersToShow: mapPref(MENU_MAX_CLUSTERS),

213
config/product/virtual.js Normal file
View File

@ -0,0 +1,213 @@
import { HCI, NODE, CONFIG_MAP } from '@/config/types';
import {
STATE, NAME as NAME_COL, AGE, NAMESPACE, IMAGE_DOWNLOAD_SIZE,
FINGERPRINT
} from '@/config/table-headers';
import { DSL } from '@/store/type-map';
export const NAME = 'virtual';
const TEMPLATE = HCI.VM_VERSION;
const CLOUD_TEMPLATE = 'cloudTemplate';
const HOST = 'host';
export function init(store) {
const {
product,
basicType,
headers,
configureType,
virtualType,
} = DSL(store, NAME);
product({
inStore: 'virtual',
removable: false,
showNamespaceFilter: true,
showClusterSwitcher: false,
icon: 'compass'
});
basicType(['virtual-dashboard']);
virtualType({
ifHaveType: NODE,
label: store.getters['i18n/t']('harvester.dashboard.label'),
group: 'Root',
labelDisplay: 'harvester.nav.dashboard',
namespaced: true,
name: 'virtual-dashboard',
weight: 500,
route: { name: 'c-cluster-virtual' },
exact: true,
});
configureType(HOST, {
location: {
name: 'c-cluster-product-resource',
params: { resource: HOST },
},
resource: NODE,
useCustomInImport: true
});
configureType(HOST, { isCreatable: false, isEditable: true });
basicType([HOST]);
virtualType({
ifHaveType: NODE,
label: store.getters['i18n/t']('harvester.host.label'),
group: 'Root',
labelDisplay: 'harvester.typeLabel.host',
name: HOST,
namespaced: true,
weight: 399,
route: {
name: 'c-cluster-product-resource',
params: { resource: HOST }
},
exact: false,
});
basicType([HCI.VM]);
virtualType({
label: store.getters['i18n/t']('harvester.virtualMachine.label'),
group: 'root',
name: HCI.VM,
namespaced: true,
weight: 299,
route: {
name: 'c-cluster-product-resource',
params: { resource: HCI.VM }
},
exact: false,
});
basicType([HCI.DATA_VOLUME]);
virtualType({
label: store.getters['i18n/t']('harvester.volume.label'),
group: 'root',
name: HCI.DATA_VOLUME,
namespaced: true,
weight: 199,
route: {
name: 'c-cluster-product-resource',
params: { resource: HCI.DATA_VOLUME }
},
exact: false,
});
basicType([HCI.IMAGE]);
headers(HCI.IMAGE, [STATE, NAME_COL, NAMESPACE, /* IMAGE_PROGRESS, IMAGE_MESSAGE, */IMAGE_DOWNLOAD_SIZE, AGE]);
virtualType({
label: store.getters['i18n/t']('harvester.image.label'),
group: 'root',
name: HCI.IMAGE,
namespaced: true,
weight: 99,
route: {
name: 'c-cluster-product-resource',
params: { resource: HCI.IMAGE }
},
exact: false,
});
basicType([
TEMPLATE,
HCI.NETWORK_ATTACHMENT,
HCI.BACKUP,
HCI.SSH,
CLOUD_TEMPLATE,
HCI.SETTING
], 'advanced');
configureType(HCI.CLUSTER_NETWORK, { realResource: HCI.SETTING, showState: false });
virtualType({
label: store.getters['i18n/t']('harvester.vmTemplate.label'),
group: 'root',
name: TEMPLATE,
namespaced: true,
weight: 289,
route: {
name: 'c-cluster-product-resource',
params: { resource: TEMPLATE }
},
exact: false,
});
configureType(HCI.BACKUP, {
DisableEditInDetail: true,
showListMasthead: false
});
virtualType({
label: store.getters['i18n/t']('harvester.backup.label'),
name: HCI.BACKUP,
namespaced: true,
weight: 200,
route: {
name: 'c-cluster-product-resource',
params: { resource: HCI.BACKUP }
},
exact: false,
});
configureType(HCI.NETWORK_ATTACHMENT, { isEditable: false, showState: false });
virtualType({
label: store.getters['i18n/t']('harvester.network.label'),
name: HCI.NETWORK_ATTACHMENT,
namespaced: true,
weight: 189,
route: {
name: 'c-cluster-product-resource',
params: { resource: HCI.NETWORK_ATTACHMENT }
},
exact: false,
});
headers(HCI.SSH, [STATE, NAME_COL, NAMESPACE, FINGERPRINT, AGE]);
virtualType({
label: store.getters['i18n/t']('harvester.sshKey.label'),
name: HCI.SSH,
namespaced: true,
weight: 170,
route: {
name: 'c-cluster-product-resource',
params: { resource: HCI.SSH }
},
exact: false,
});
configureType(CLOUD_TEMPLATE, {
location: {
name: 'c-cluster-product-resource',
params: { resource: CLOUD_TEMPLATE },
},
resource: CONFIG_MAP,
useCustomInImport: true
});
virtualType({
label: store.getters['i18n/t']('harvester.cloudTemplate.label'),
name: CLOUD_TEMPLATE,
namespaced: true,
weight: 87,
route: {
name: 'c-cluster-product-resource',
params: { resource: CLOUD_TEMPLATE }
},
exact: false,
});
// settings
configureType(HCI.SETTING, { isCreatable: false });
virtualType({
ifHaveType: HCI.SETTING,
label: store.getters['i18n/t']('harvester.setting.label'),
name: HCI.SETTING,
namespaced: true,
weight: -1,
route: {
name: 'c-cluster-product-resource',
params: { resource: HCI.SETTING }
},
exact: false
});
}

View File

@ -86,6 +86,47 @@ export const ALLOWED_SETTINGS = {
},
};
// harvester Settings ID
const HCI_SETTING = {
API_UI_SOURCE: 'api-ui-source',
AUTH_TOKEN_MAX_TTL_MINUTES: 'auth-token-max-ttl-minutes',
BACKUP_TARGET: 'backup-target',
LOG_LEVEL: 'log-level',
RANCHER_ENABLED: 'rancher-enabled',
SERVER_URL: 'server-url',
SERVER_VERSION: 'server-version',
UI_INDEX: 'ui-index',
UPGRADE_CHECKER_ENABLED: 'upgrade-checker-enabled',
UPGRADE_CHECKER_URL: 'upgrade-checker-url',
VLAN: 'harvester-system/vlan',
// DEFAULT_STORAGE_CLASS: 'default-storage-class'
};
export const HCI_ALLOWED_SETTINGS = {
[HCI_SETTING.API_UI_SOURCE]: {
kind: 'enum',
options: ['auto', 'external', 'bundled']
},
[HCI_SETTING.AUTH_TOKEN_MAX_TTL_MINUTES]: {},
[HCI_SETTING.BACKUP_TARGET]: {
kind: 'json', from: 'import', disableReset: true
},
[HCI_SETTING.LOG_LEVEL]: {
kind: 'enum',
options: ['info', 'debug', 'trace']
},
[HCI_SETTING.RANCHER_ENABLED]: { kind: 'boolean' },
[HCI_SETTING.SERVER_VERSION]: {},
[HCI_SETTING.SERVER_URL]: {},
[HCI_SETTING.UI_INDEX]: { kind: 'url' },
[HCI_SETTING.UPGRADE_CHECKER_ENABLED]: { kind: 'boolean' },
[HCI_SETTING.UPGRADE_CHECKER_URL]: { kind: 'url' },
[HCI_SETTING.VLAN]: {
kind: 'custom', from: 'import', alias: 'vlan'
},
// [HCI_SETTING.DEFAULT_STORAGE_CLASS]: {}
};
export const fetchOrCreateSetting = async(store, id, val, save = true) => {
let setting;

View File

@ -831,3 +831,40 @@ export const STATE_NORMAN = {
default: 'unknown',
formatter: 'BadgeStateFormatter',
};
/**
* Harvester
*/
// image
export const IMAGE_DOWNLOAD_SIZE = {
name: 'downloadedBytes',
labelKey: 'tableHeaders.size',
value: 'status.size',
sort: 'status.size',
formatter: 'ByteFormat',
width: 120
};
export const IMAGE_PROGRESS = {
name: 'Uploaded',
labelKey: 'tableHeaders.progress',
value: 'status.progress',
sort: 'status.progress',
formatter: 'ImagePercentageBar',
};
export const IMAGE_MESSAGE = {
name: 'Message',
labelKey: 'tableHeaders.message',
value: 'status.conditions',
sort: 'status.conditions',
formatter: 'ImageMessage',
};
// SSH keys
export const FINGERPRINT = {
name: 'Fingerprint',
labelKey: 'tableHeaders.fingerprint',
value: 'status.fingerPrint',
};

View File

@ -0,0 +1,52 @@
<script>
import Tabbed from '@/components/Tabbed';
import Tab from '@/components/Tabbed/Tab';
import LabelValue from '@/components/LabelValue';
export default {
name: 'ViewSSH',
components: {
LabelValue,
Tab,
Tabbed
},
props: {
value: {
type: Object,
required: true,
}
},
data() {
let spec = this.value.spec;
if ( !this.value.spec ) {
spec = {};
this.value.spec = spec;
this.value.metadata = { name: '' };
}
return { publicKey: this.value.spec.publicKey || '' };
},
};
</script>
<template>
<Tabbed v-bind="$attrs" class="mt-15" :side-tabs="true">
<Tab name="detail" :label="t('harvester.vmPage.detail.tabs.basics')" class="bordered-table">
<div class="row">
<div class="col span-12 keypair-card">
<LabelValue name="SSH-Key" :value="publicKey" class="mb-20" />
</div>
</div>
</Tab>
</Tabbed>
</template>
<style lang="scss">
.keypair-card DIV {
word-break: break-all;
}
</style>

View File

@ -0,0 +1,112 @@
<script>
import randomstring from 'randomstring';
import Tabbed from '@/components/Tabbed';
import Tab from '@/components/Tabbed/Tab';
import LabeledInput from '@/components/form/LabeledInput';
import CruResource from '@/components/CruResource';
import NameNsDescription from '@/components/form/NameNsDescription';
import FileSelector, { createOnSelected } from '@/components/form/FileSelector';
import CreateEditView from '@/mixins/create-edit-view';
export default {
name: 'EditSSH',
components: {
Tab,
Tabbed,
CruResource,
LabeledInput,
FileSelector,
NameNsDescription
},
mixins: [CreateEditView],
props: {
value: {
type: Object,
required: true,
}
},
data() {
if ( !this.value.spec ) {
this.value.spec = {};
this.value.metadata = { name: '' };
}
return {
publicKey: this.value.spec.publicKey || '',
randomString: '',
};
},
watch: {
publicKey(neu) {
this.value.spec.publicKey = neu;
const splitSSH = neu.split(/\s+/);
if (splitSSH.length === 3) {
if (splitSSH[2].includes('@') && !this.value.metadata.name) {
this.value.metadata.name = splitSSH[2].split('@')[0];
this.randomString = randomstring.generate(10).toLowerCase();
}
}
}
},
methods: { onKeySelected: createOnSelected('publicKey') },
};
</script>
<template>
<div class="keypair-card">
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
@apply-hooks="applyHooks"
@finish="save"
>
<div class="header mb-20">
<FileSelector
v-if="isCreate"
class="btn btn-sm bg-primary mt-10"
:label="t('generic.readFromFile')"
accept=".pub"
@selected="onKeySelected"
/>
</div>
<NameNsDescription
ref="nd"
:key="randomString"
v-model="value"
:mode="mode"
/>
<Tabbed v-bind="$attrs" class="mt-15" :side-tabs="true">
<Tab name="basic" :label="t('harvester.sshKey.tabs.basics')" :weight="1" class="bordered-table">
<LabeledInput
v-model="publicKey"
type="multiline"
:mode="mode"
:min-height="160"
:label="t('harvester.sshKey.keypair')"
required
/>
</Tab>
</Tabbed>
</CruResource>
</div>
</template>
<style lang="scss" scoped>
.header {
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,173 @@
<script>
import CreateEditView from '@/mixins/create-edit-view';
import LabeledInput from '@/components/form/LabeledInput';
import LabeledSelect from '@/components/form/LabeledSelect';
import Password from '@/components/form/Password';
import Tip from '@/components/Tip';
export default {
name: 'BackupTarget',
components: {
LabeledInput, LabeledSelect, Tip, Password
},
mixins: [CreateEditView],
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
default: 'create',
},
},
data() {
let parseDefaultValue = {};
try {
parseDefaultValue = JSON.parse(this.value.value);
} catch (error) {
parseDefaultValue = JSON.parse(this.value.default);
}
if (!parseDefaultValue.type) {
parseDefaultValue.type = 's3';
}
return {
parseDefaultValue,
errors: []
};
},
computed: {
typeOption() {
return [{
value: 'nfs',
label: 'NFS'
}, {
value: 's3',
label: 'S3'
}];
},
virtualHostedStyleType() {
return [{
value: true,
label: 'True'
}, {
value: false,
label: 'False'
}];
},
isS3() {
return this.parseDefaultValue.type === 's3';
},
endpointPlaceholder() {
return this.isS3 ? '' : 'nfs://server:/path/';
}
},
watch: {
'parseDefaultValue.type'(neu) {
delete this.parseDefaultValue.accessKeyId;
delete this.parseDefaultValue.secretAccessKey;
delete this.parseDefaultValue.bucketName;
delete this.parseDefaultValue.bucketRegion;
delete this.parseDefaultValue.cert;
delete this.parseDefaultValue.endpoint;
}
},
created() {
this.update();
},
methods: {
update() {
const value = JSON.stringify(this.parseDefaultValue);
this.$set(this.value, 'value', value);
}
}
};
</script>
<template>
<div class="row" @input="update">
<div class="col span-12">
<LabeledSelect v-model="parseDefaultValue.type" class="mb-20" :label="t('harvester.fields.type')" :options="typeOption" @input="update" />
<LabeledInput v-model="parseDefaultValue.endpoint" class="mb-5" :placeholder="endpointPlaceholder" :mode="mode" label="Endpoint" />
<Tip class="mb-20" icon="icons icon-h-question" :text="t('harvester.backUpPage.backupTargetTip')" />
<template v-if="isS3">
<LabeledInput
v-model="parseDefaultValue.bucketName"
class="mb-20"
:mode="mode"
label="Bucket Name"
required
/>
<LabeledInput
v-model="parseDefaultValue.bucketRegion"
class="mb-20"
:mode="mode"
label="Bucket Region"
required
/>
<LabeledInput
v-model="parseDefaultValue.accessKeyId"
:placeholder="t('harvester.setting.placeholder.accessKeyId')"
class="mb-20"
:mode="mode"
label="Access Key ID"
required
/>
<Password
v-model="parseDefaultValue.secretAccessKey"
class="mb-20"
:mode="mode"
:placeholder="t('harvester.setting.placeholder.secretAccessKey')"
label="Secret Access Key"
required
/>
<LabeledInput
v-model="parseDefaultValue.cert"
type="multiline"
class="mb-20"
:placeholder="t('harvester.setting.placeholder.cert')"
:mode="mode"
:min-height="120"
label="Certificate"
/>
<LabeledSelect v-model="parseDefaultValue.virtualHostedStyle" class="mb-20" label="Virtual Hosted-Style" :options="virtualHostedStyleType" @input="update" />
</template>
</div>
</div>
</template>
<style lang="scss" scoped>
p {
display: flex;
align-items: center;
}
.icon-h-question {
font-size: 24px;
}
.tip {
font-size: 15px;
}
</style>

View File

@ -0,0 +1,191 @@
<script>
import CruResource from '@/components/CruResource';
import RadioGroup from '@/components/form/RadioGroup';
import LabeledInput from '@/components/form/LabeledInput';
import LabeledSelect from '@/components/form/LabeledSelect';
import TextAreaAutoGrow from '@/components/form/TextAreaAutoGrow';
import CreateEditView from '@/mixins/create-edit-view';
import { HCI_ALLOWED_SETTINGS } from '@/config/settings';
export default {
components: {
CruResource,
LabeledInput,
LabeledSelect,
RadioGroup,
TextAreaAutoGrow
},
mixins: [CreateEditView],
data() {
const t = this.$store.getters['i18n/t'];
const setting = HCI_ALLOWED_SETTINGS[this.value.id];
let enumOptions = [];
if (setting.kind === 'enum' ) {
enumOptions = setting.options.map(id => ({
label: `advancedSettings.enum.${ this.value.id }.${ id }`,
value: id,
}));
}
const canReset = !setting.disableReset && (!!this.value.default || this.value.canReset);
if (this.value.value === undefined) {
this.$set(this.value, 'value', null);
}
this.value.value = this.value.value || this.value.default;
return {
setting,
description: t(`advancedSettings.descriptions.${ this.value.id }`),
editHelp: t(`advancedSettings.editHelp.${ this.value.id }`),
enumOptions,
canReset,
errors: [],
hasCustomComponent: false,
customComponent: null
};
},
computed: {
doneLocationOverride() {
return this.value.doneOverride;
},
},
created() {
let customComponent = false;
const resource = this.$route.params.resource;
const name = this.value.metadata.name;
const path = `${ resource }/${ name }`;
const hasCustomComponent = this.$store.getters['type-map/haveComponent'](path);
if ( hasCustomComponent ) {
customComponent = this.$store.getters['type-map/importComponent'](path);
}
this.hasCustomComponent = hasCustomComponent;
this.customComponent = customComponent;
},
methods: {
saveSettings(done) {
const t = this.$store.getters['i18n/t'];
// Validate the JSON if the setting is a json value
if (this.setting.kind === 'json') {
try {
JSON.parse(this.value.value);
this.errors = [];
} catch (e) {
this.errors = [t('advancedSettings.edit.invalidJSON')];
return done(false);
}
}
this.save(done);
},
useDefault(ev) {
// Lose the focus on the button after click
if (ev && ev.srcElement) {
ev.srcElement.blur();
}
if (this.value.default) {
this.value.value = this.value.default;
} else {
this.value = this.value.defaultValue;
}
},
}
};
</script>
<template>
<CruResource
class="route"
:done-route="'c-cluster-product-resource'"
:errors="errors"
:mode="mode"
:resource="value"
:subtypes="[]"
:can-yaml="false"
@error="e=>errors = e"
@finish="saveSettings"
@cancel="done"
>
<h4>{{ description }}</h4>
<h5 v-if="editHelp" class="edit-help" v-html="editHelp" />
<div class="edit-change mt-20">
<h5 v-t="'advancedSettings.edit.changeSetting'" />
<button :disabled="!canReset" type="button" class="btn role-primary" @click="useDefault">
{{ t('advancedSettings.edit.useDefault') }}
</button>
</div>
<div class="mt-20">
<div v-if="setting.from === 'import'">
<component
:is="customComponent"
v-if="hasCustomComponent"
v-model="value"
/>
</div>
<div v-else-if="setting.kind === 'enum'">
<LabeledSelect
v-model="value.value"
:label="t('advancedSettings.edit.value')"
:localized-label="true"
:mode="mode"
:options="enumOptions"
/>
</div>
<div v-else-if="setting.kind === 'boolean'">
<RadioGroup
v-model="value.value"
name="settings_value"
:labels="[t('advancedSettings.edit.trueOption'), t('advancedSettings.edit.falseOption')]"
:options="['true', 'false']"
/>
</div>
<div v-else-if="setting.kind === 'multiline' || setting.kind === 'json'">
<TextAreaAutoGrow
v-model="value.value"
:min-height="254"
/>
</div>
<div v-else>
<LabeledInput
v-model="value.value"
:label="t('advancedSettings.edit.value')"
/>
</div>
</div>
</CruResource>
</template>
<style lang="scss" scoped>
.edit-change {
align-items: center;
display: flex;
> h5 {
flex: 1;
}
}
::v-deep .edit-help code {
padding: 1px 5px;
}
</style>

View File

@ -0,0 +1,19 @@
<script>
import Setting from '@/edit/harvesterhci.io.setting';
export default {
name: 'EditClusterNetwork',
components: { Setting },
props: {
value: {
type: Object,
required: true,
}
},
};
</script>
<template>
<Setting :value="value" />
</template>

View File

@ -0,0 +1,69 @@
<script>
import Vue from 'vue';
import LabeledInput from '@/components/form/LabeledInput';
import RadioGroup from '@/components/form/RadioGroup';
import Tip from '@/components/Tip';
import CreateEditView from '@/mixins/create-edit-view';
export default {
name: 'EditVlan',
components: {
LabeledInput,
RadioGroup,
Tip
},
mixins: [CreateEditView],
props: {
value: {
type: Object,
required: true,
},
},
data() {
if (!this.value.config) {
Vue.set(this.value, 'config', { defaultPhysicalNIC: '' });
}
return {};
},
computed: {
doneLocationOverride() {
return this.value.listLocation;
}
},
};
</script>
<template>
<div>
<RadioGroup
v-model="value.enable"
class="mb-20"
name="model"
:options="[true,false]"
:labels="[t('generic.enabled'), t('generic.disabled')]"
/>
<LabeledInput
v-if="value.enable"
v-model="value.config.defaultPhysicalNIC"
:label="t('harvester.setting.defaultPhysicalNIC')"
class="mb-5"
/>
<Tip v-if="value.enable" icon="icons icon-h-question" :text="t('harvester.setting.vlanChangeTip')" />
</div>
</template>
<style lang="scss" scoped>
::v-deep .radio-group {
display: flex;
.radio-container {
margin-right: 30px;
}
}
</style>

View File

@ -0,0 +1,196 @@
<script>
import { mapGetters } from 'vuex';
import { HCI } from '@/config/types';
import { HCI_ALLOWED_SETTINGS } from '@/config/settings';
import { allHash } from '@/utils/promise';
import Banner from '@/components/Banner';
import Loading from '@/components/Loading';
import { DEV } from '@/store/prefs';
export default {
components: { Banner, Loading },
async fetch() {
const isDev = this.$store.getters['prefs/get'](DEV);
const rows = await allHash({
clusterNetwork: this.$store.dispatch('cluster/findAll', { type: HCI.CLUSTER_NETWORK }),
haversterSettings: this.$store.dispatch('cluster/findAll', { type: HCI.SETTING }),
});
const allRows = [...rows.clusterNetwork, ...rows.haversterSettings];
// Map settings from array to object keyed by id
const settingsMap = allRows.reduce((res, s) => {
res[s.id] = s;
return res;
}, {});
const initSettings = [];
// Combine the allowed settings with the data from the API
Object.keys(HCI_ALLOWED_SETTINGS).forEach((setting) => {
if (!settingsMap[setting]) {
return;
}
const realSetting = HCI_ALLOWED_SETTINGS[setting]?.alias || setting;
const s = {
...HCI_ALLOWED_SETTINGS[setting],
id: realSetting,
data: settingsMap[setting],
};
s.hide = s.canHide = (s.kind === 'json' || s.kind === 'multiline');
// There are only 2 actions that can be enabled - Edit Setting or View in API
// If neither is available for this setting then we hide the action menu button
s.hasActions = !s.readOnly || isDev;
initSettings.push(s);
});
this.initSettings = initSettings;
},
data() {
return { initSettings: [] };
},
computed: {
...mapGetters({ t: 'i18n/t' }),
settings() {
return this.initSettings.map((setting) => {
const s = setting;
if (s.kind === 'json') {
s.json = JSON.stringify(JSON.parse(s.data.value || s.data.default), null, 2);
} else if (s.kind === 'enum') {
const v = s.data.value || s.data.default;
s.enum = `advancedSettings.enum.${ s.id }.${ v }`;
} else if (s.kind === 'custom') {
s.custom = s.data.customValue;
}
return {
...s,
description: this.t(`advancedSettings.descriptions.${ s.id }`),
customized: (!s.readonly && s.data.value && s.data.value !== s.data.default) || s.data.hasCustomized
};
});
}
},
methods: {
showActionMenu(e, setting) {
const actionElement = e.srcElement;
this.$store.commit(`action-menu/show`, {
resources: setting.data,
elem: actionElement
});
},
getSettingOption(id) {
return HCI_ALLOWED_SETTINGS.find(setting => setting.id === id);
},
toogleHide(s) {
this.initSettings.find((setting) => {
if (setting.id === s.id) {
setting.hide = !setting.hide;
}
});
}
}
};
</script>
<template>
<Loading v-if="!settings" />
<div v-else>
<Banner color="warning" class="settings-banner">
<div>
{{ t('advancedSettings.subtext') }}
</div>
</Banner>
<div v-for="setting in settings" :key="setting.id" class="advanced-setting mb-20">
<div class="header">
<div class="title">
<h1>{{ setting.id }}<span v-if="setting.customized" class="modified">Modified</span></h1>
<h2>{{ setting.description }}</h2>
</div>
<div v-if="setting.hasActions" class="action">
<button aria-haspopup="true" aria-expanded="false" type="button" class="btn btn-sm role-multi-action actions" @click="showActionMenu($event, setting)">
<i class="icon icon-actions" />
</button>
</div>
</div>
<div value>
<div v-if="setting.hide">
<button class="btn btn-sm role-primary" @click="toogleHide(setting)">
{{ t('advancedSettings.show') }} {{ setting.id }}
</button>
</div>
<div v-else class="settings-value">
<pre v-if="setting.kind === 'json'">{{ setting.json }}</pre>
<pre v-else-if="setting.kind === 'multiline'">{{ setting.data.value || setting.data.default }}</pre>
<pre v-else-if="setting.kind === 'enum'">{{ t(setting.enum) }}</pre>
<pre v-else-if="setting.kind === 'custom' && setting.custom"> {{ setting.custom }}</pre>
<pre v-else-if="setting.data.value || setting.data.default">{{ setting.data.value || setting.data.default }}</pre>
<pre v-else class="text-muted">&lt;{{ t('advancedSettings.none') }}&gt;</pre>
</div>
<div v-if="setting.canHide && !setting.hide" class="mt-5">
<button class="btn btn-sm role-primary" @click="toogleHide(setting)">
{{ t('advancedSettings.hide') }} {{ setting.id }}
</button>
</div>
</div>
<Banner v-if="setting.data.errMessage" color="error mt-5" class="settings-banner">
{{ setting.data.errMessage }}
</Banner>
</div>
</div>
</template>
<style lang='scss' scoped>
.settings-banner {
margin-top: 0;
}
.advanced-setting {
border: 1px solid var(--border);
padding: 20px;
border-radius: var(--border-radius);
h1 {
font-size: 14px;
}
h2 {
font-size: 12px;
margin-bottom: 0;
opacity: 0.8;
}
}
.settings-value pre {
margin: 0;
}
.header {
display: flex;
margin-bottom: 20px;
}
.title {
flex: 1;
}
.modified {
margin-left: 10px;
border: 1px solid var(--primary);
border-radius: 5px;
padding: 2px 10px;
font-size: 12px;
}
</style>

View File

@ -0,0 +1,125 @@
import { findBy } from '@/utils/array';
import { HCI_ALLOWED_SETTINGS } from '@/config/settings';
export default {
_availableActions() {
const out = this._standardActions;
const toFilter = ['cloneYaml', 'download', 'goToEditYaml', 'goToViewYaml', 'goToViewConfig', 'promptRemove'];
const actions = out.map((O) => {
const enabled = toFilter.includes(O.action) ? false : O.enabled;
const bulkable = toFilter.includes(O.action) ? false : O?.bulkable;
return {
...O,
enabled,
bulkable
};
});
const editAction = actions.find(action => action.action === 'goToEdit');
if (editAction) {
editAction.label = this.t('advancedSettings.edit.label');
}
return actions;
},
hasCustomized() {
const setting = HCI_ALLOWED_SETTINGS[this.id];
const readonly = !!setting.readOnly;
return !readonly && this.value && this.value !== this.default;
},
backupTagetetIsEmpty() {
return !this.value;
},
errMessage() {
if (this.metadata?.state?.error === true) {
return this.metadata.state.message;
} else {
return false;
}
},
configuredCondition() {
return findBy((this?.status?.conditions || []), 'type', 'configured') || {};
},
valueOrDefaultValue() {
return this.value || this.default;
},
upgradeableVersion() {
const value = this.value || '';
if (!value) {
return [];
}
return value.split(',').sort((a, b) => {
return a > b ? -1 : 1;
}).map( (V) => {
return {
label: V,
value: V
};
});
},
currentVersion() {
return this.value || '';
},
displayValue() { // Select the field you want to display
if (this.id === 'backup-target') {
return this.parseValue?.endpoint || ' ';
}
return null;
},
parseValue() {
let parseDefaultValue = {};
try {
parseDefaultValue = JSON.parse(this.value);
} catch (err) {
parseDefaultValue = JSON.parse(this.default);
}
return parseDefaultValue;
},
isS3() {
return this.parseValue.type === 's3';
},
isNFS() {
return this.parseValue.type === 'nfs';
},
customValidationRules() {
const id = this.id;
const out = [];
switch (id) {
case 'backup-target':
out.push( {
nullable: false,
path: 'value',
required: true,
type: 'string',
validators: ['backupTarget'],
});
break;
}
return out;
},
};

View File

@ -0,0 +1,88 @@
import { HCI } from '@/config/types';
import { clone } from '@/utils/object';
export default {
availableActions() {
let out = this._standardActions;
const toFilter = ['goToClone', 'cloneYaml', 'goToViewYaml', 'goToViewConfig', 'promptRemove', 'goToEditYaml', 'download'];
out = out.filter((action) => {
if (!toFilter.includes(action.action)) {
return action;
}
});
const editAction = out.find(action => action.action === 'goToEdit');
if (editAction) {
editAction.label = this.t('advancedSettings.edit.label');
}
return out;
},
doneOverride() {
const detailLocation = clone(this.listLocation);
detailLocation.params.resource = HCI.SETTING;
return detailLocation;
},
// vlan
canUseVlan() {
return this.isVlanOpen && this.defaultPhysicalNic.length > 0;
},
canReset() {
return true;
},
defaultValue() {
this.enable = false;
if (this.config) { // initializing: the config value is empty
this.config.defaultPhysicalNIC = '';
}
return this;
},
isVlanOpen() {
return !!this.enable;
},
defaultPhysicalNic() {
return this?.config?.defaultPhysicalNIC;
},
displayValue() { // Select the field you want to display
if (this.enable) {
return this?.config?.defaultPhysicalNIC || '';
}
},
customValue() {
return this.enable ? this?.config?.defaultPhysicalNIC : null;
},
hasCustomized() {
return this.enable;
},
customValidationRules() {
const out = [];
if (this.enable) {
out.push({
nullable: false,
path: 'config.defaultPhysicalNIC',
required: true,
translationKey: 'harvester.setting.validation.physicalNIC',
type: 'string',
});
}
return out;
},
};

View File

@ -72,6 +72,7 @@
"nuxt": "2.14.6",
"papaparse": "^5.3.0",
"portal-vue": "^2.1.5",
"randomstring": "^1.2.1",
"require-extension-hooks": "^0.3.3",
"require-extension-hooks-babel": "^1.0.0-beta.1",
"require-extension-hooks-vue": "^3.0.0",

View File

@ -28,9 +28,8 @@ export const plugins = [
namespace: 'management', baseUrl: '/v1', modelBaseClass: BY_TYPE
}),
Steve({ namespace: 'cluster', baseUrl: '' }), // URL dynamically set for the selected cluster
Steve({
namespace: 'rancher', baseUrl: '/v3', modelBaseClass: NORMAN_CLASS
}),
Steve({ namespace: 'rancher', baseUrl: '/v3' }),
Steve({ namespace: 'virtual', baseUrl: '' }),
];
export const state = () => {
@ -587,12 +586,15 @@ export const actions = {
}
const clusterBase = `/k8s/clusters/${ escape(id) }/v1`;
const virtualBase = `/k8s/clusters/${ escape(id) }/v1/harvester`;
// Update the Steve client URLs
commit('cluster/applyConfig', { baseUrl: clusterBase });
commit('virtual/applyConfig', { baseUrl: virtualBase });
await Promise.all([
dispatch('cluster/loadSchemas', true),
dispatch('virtual/loadSchemas', true),
]);
dispatch('cluster/subscribe');
@ -606,10 +608,12 @@ export const actions = {
};
const res = await allHash({
projects: isRancher && dispatch('management/findAll', projectArgs),
counts: dispatch('cluster/findAll', { type: COUNT }),
namespaces: dispatch('cluster/findAll', { type: NAMESPACE }),
navLinks: !!getters['cluster/schemaFor'](UI.NAV_LINK) && dispatch('cluster/findAll', { type: UI.NAV_LINK }),
projects: isRancher && dispatch('management/findAll', projectArgs),
counts: dispatch('cluster/findAll', { type: COUNT }),
virtualCount: dispatch('virtual/findAll', { type: COUNT }),
namespaces: dispatch('cluster/findAll', { type: NAMESPACE }),
virtualNamespaces: dispatch('virtual/findAll', { type: NAMESPACE }),
navLinks: !!getters['cluster/schemaFor'](UI.NAV_LINK) && dispatch('cluster/findAll', { type: UI.NAV_LINK }),
});
await dispatch('cleanNamespaces');

View File

@ -118,7 +118,9 @@ import { clone, get } from '@/utils/object';
import {
ensureRegex, escapeHtml, escapeRegex, ucFirst, pluralize
} from '@/utils/string';
import { importList, importDetail, importEdit, loadProduct } from '@/utils/dynamic-importer';
import {
importList, importDetail, importEdit, loadProduct, importComponent
} from '@/utils/dynamic-importer';
import { NAME as EXPLORER } from '@/config/product/explorer';
import isObject from 'lodash/isObject';
@ -995,6 +997,24 @@ export const getters = {
};
},
haveComponent(state, getters) {
return (path) => {
try {
require.resolve(`@/edit/${ path }`);
return true;
} catch (e) {
return false;
}
};
},
importComponent(state, getters) {
return (path) => {
return importComponent(path);
};
},
importList(state, getters) {
return (rawType) => {
const type = getters.componentFor(rawType);

View File

@ -83,3 +83,11 @@ export function loadTranslation(name) {
// Note: directly returns the import, not a function
return import(/* webpackChunkName: "[request]" */ `@/assets/translations/${name}.yaml`);
}
export function importComponent(path) {
if ( !path ) {
throw new Error('Path required');
}
return () => import(/* webpackChunkName: "import-component" */ `@/edit/${ path }`);
}

View File

@ -4311,6 +4311,11 @@ array-union@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
array-uniq@1.0.2:
version "1.0.2"
resolved "https://registry.nlark.com/array-uniq/download/array-uniq-1.0.2.tgz?cache=0&sync_timestamp=1620042102127&other_urls=https%3A%2F%2Fregistry.nlark.com%2Farray-uniq%2Fdownload%2Farray-uniq-1.0.2.tgz#5fcc373920775723cfd64d65c64bef53bf9eba6d"
integrity sha1-X8w3OSB3VyPP1k1lxkvvU7+eum0=
array-uniq@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
@ -12359,6 +12364,14 @@ randomfill@^1.0.3:
randombytes "^2.0.5"
safe-buffer "^5.1.0"
randomstring@^1.2.1:
version "1.2.1"
resolved "https://registry.nlark.com/randomstring/download/randomstring-1.2.1.tgz#71cd3cda24ad1b7e0b65286b3aa5c10853019349"
integrity sha1-cc082iStG34LZShrOqXBCFMBk0k=
dependencies:
array-uniq "1.0.2"
randombytes "2.0.3"
range-parser@^1.2.1, range-parser@~1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"