mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-11-15 13:26:15 +08:00
chore: backport shell 3.0.14 and SUSE Virtualization naming to v1.9 (#1215)
* chore(deps): bump @rancher/shell to 3.0.13 (#1130) Clears 26 of the 54 open Dependabot alerts (axios, dompurify, serve-static, and drops vulnerable send / @tootallnate/once copies). 3.0.13 ships the flat-config ESLint 10 toolchain, so lint now routes through the @rancher/shell launcher (keeping the legacy .eslintrc on ESLint 7); a prelint step installs an ESLint-7-compatible eslint-plugin-jest into the isolated toolchain. Conflicts resolved: yarn.lock (yarn.lock regenerated with yarn install; package.json keeps the release branch dependencies) Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> (cherry picked from commit00584f1d19) * feat: Add a unit test target (jest) to the UI extension and run it in CI (#1192) - Introduce test targets. - Adding additional tests. Related to: https://github.com/harvester/harvester/issues/11892 Signed-off-by: Volker Theile <vtheile@suse.com> (cherry picked from commit71141b257a) Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * chore(deps): bump @rancher/shell to 3.0.14 (#1208) * chore(deps): bump @rancher/shell to 3.0.14 3.0.14 resolves [[name]] tokens in translations from values registered as l10n globals, which the standalone UI needs to pick the product name. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: save cloud-config template edits with @rancher/shell 3.0.14 The YamlEditor of shell 3.0.14 no longer emits onChanges, so editing a template saved its old cloud config. Copy the text from update:value, which both shell versions emit. Jest transforms @replit/codemirror-vim-core, an ESM-only dependency of the new YamlEditor, like the dashboard does. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: keep the YAML editor heights with CodeMirror 6 The min-height rules targeted the CodeMirror 5 element, so the editors shrank to their text. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: reject IP ranges the network settings don't support The CIDR check of @rancher/shell 3.0.14 also accepts short forms such as 10.1/16, extra parts and IPv6, so the VM migration, RWX and storage network settings saved them. Use a strict IPv4 CIDR check of our own. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: keep the look of the file selector buttons The FileSelector of @rancher/shell 3.0.14 no longer takes button classes and renders a medium secondary button. Pass the variant and size that match the previous primary and secondary buttons. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: keep Enter in a migration wizard field on the same step The Wizard of @rancher/shell 3.0.14 renders its step headers as submit buttons, so Enter in a text field clicked the first one and jumped back to step 1. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: show the created message after a restore, upgrade or upload Since @rancher/shell 3.0.14, save() no longer shows the message for resources with a generated name; callers show it with notifyGeneratedName(). Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * refactor: fold the logging output buffer with the CodeMirror 6 helpers The YAML editor of @rancher/shell 3.0.14 is CodeMirror 6, and its CodeMirror 5 methods are deprecated. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: keep the shell's table views off in standalone @rancher/shell 3.0.14 turns its table views on for every list in the standalone UI, as the server has no ui-configurable-tables feature and the default is on. The table views drop the filters the lists put in the table header, which also left the Images list empty. Add the feature, disabled, to the management store in standalone only. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: reject zero-padded octets in IP ranges Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> --------- Conflicts resolved: package.json, yarn.lock (yarn.lock regenerated with yarn install; package.json keeps the release branch dependencies) Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> (cherry picked from commitd6f6f7c54c) * feat: use the SUSE Virtualization product name on Prime (#1202) * feat: switch the product name with Rancher Prime Use a [[Harvester]] token for the product name in the translations, so Rancher 2.16 fills it from the l10n global that harvester-manager registers: SUSE Virtualization on Rancher Prime, Harvester otherwise. Rancher before 2.16 shows the tokens as written, so on those hosts (and standalone) the extension resolves them to Harvester itself. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: switch the standalone product name with the server flavor Standalone has no Rancher Prime, so register the [[Harvester]] l10n global from the server-flavor setting instead: SUSE Virtualization on prime, Harvester otherwise. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: point the network policy banner to the documentation Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: use the SUSE theme in standalone Prime Standalone Prime kept the harvester brand and only overrode the primary colour, so the rest of the palette and the fonts didn't match Rancher Prime. Switch the ui-brand setting to suse in the store when the server flavor is prime, which makes the shell load the SUSE theme. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * fix: keep the SUSE theme after a brand setting change event The watcher also reads the setting's value, so a change from the server loaded into the same object switches the brand back to suse. Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> --------- Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> (cherry picked from commit6e0e0237ef) --------- Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> Signed-off-by: Volker Theile <vtheile@suse.com> Co-authored-by: Volker Theile <vtheile@suse.com>
This commit is contained in:
co-authored by
Volker Theile
parent
47d337aa7d
commit
fcc8f27f40
@@ -128,6 +128,7 @@ export default {
|
||||
|
||||
try {
|
||||
await proxyResource.save();
|
||||
proxyResource.notifyGeneratedName?.();
|
||||
|
||||
this.cancel();
|
||||
} catch (err) {
|
||||
|
||||
@@ -19,7 +19,8 @@ export default {
|
||||
<div class="row mb-20">
|
||||
<div class="col span-12">
|
||||
<FileSelector
|
||||
class="btn btn-sm bg-primary mb-10"
|
||||
variant="primary"
|
||||
class="mb-10"
|
||||
:label="t('generic.readFromFile')"
|
||||
@selected="onKeySelected"
|
||||
/>
|
||||
|
||||
@@ -191,7 +191,8 @@ export default {
|
||||
>
|
||||
<div class="col span-12">
|
||||
<FileSelector
|
||||
class="btn btn-sm bg-primary mb-10"
|
||||
variant="primary"
|
||||
class="mb-10"
|
||||
:label="t('generic.readFromFile')"
|
||||
@selected="onKeySelected"
|
||||
/>
|
||||
@@ -213,9 +214,7 @@ $yaml-height: 540px;
|
||||
:deep() .yaml-editor{
|
||||
flex: 1;
|
||||
min-height: $yaml-height;
|
||||
& .code-mirror .CodeMirror {
|
||||
position: initial;
|
||||
height: auto;
|
||||
& .code-mirror .cm-editor {
|
||||
min-height: $yaml-height;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import ArrayList from '@shell/components/form/ArrayList';
|
||||
import { isValidCIDR } from '@shell/utils/validators/cidr';
|
||||
import { isValidCIDR } from '@pkg/harvester/utils/regular';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
|
||||
@@ -62,7 +62,8 @@ export default {
|
||||
<div class="chooseFile">
|
||||
<FileSelector
|
||||
:include-file-name="true"
|
||||
class="btn btn-sm bg-primary mr-20"
|
||||
variant="primary"
|
||||
class="mr-20"
|
||||
label="Choose File"
|
||||
@selected="onKeySelectedCa('publicCertificate', $event)"
|
||||
/>
|
||||
@@ -80,7 +81,8 @@ export default {
|
||||
<div class="chooseFile">
|
||||
<FileSelector
|
||||
:include-file-name="true"
|
||||
class="btn btn-sm bg-primary mr-20"
|
||||
variant="primary"
|
||||
class="mr-20"
|
||||
label="Choose File"
|
||||
@selected="onKeySelectedCa('privateKey', $event)"
|
||||
/>
|
||||
@@ -98,7 +100,8 @@ export default {
|
||||
<div class="chooseFile">
|
||||
<FileSelector
|
||||
:include-file-name="true"
|
||||
class="btn btn-sm bg-primary mr-20"
|
||||
variant="primary"
|
||||
class="mr-20"
|
||||
label="Choose File"
|
||||
@selected="onKeySelectedCa('ca', $event)"
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
|
||||
import { RadioGroup } from '@components/Form/Radio';
|
||||
import ArrayList from '@shell/components/form/ArrayList';
|
||||
import { isValidCIDR } from '@shell/utils/validators/cidr';
|
||||
import { isValidCIDR } from '@pkg/harvester/utils/regular';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
import { Banner } from '@components/Banner';
|
||||
import Tip from '@shell/components/Tip';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RadioGroup } from '@components/Form/Radio';
|
||||
import { Banner } from '@components/Banner';
|
||||
import ArrayList from '@shell/components/form/ArrayList';
|
||||
import { allHash } from '@shell/utils/promise';
|
||||
import { isValidCIDR } from '@shell/utils/validators/cidr';
|
||||
import { isValidCIDR } from '@pkg/harvester/utils/regular';
|
||||
import { NODE } from '@shell/config/types';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
import { HCI } from '../../types';
|
||||
|
||||
@@ -57,8 +57,12 @@ import {
|
||||
} from './table-headers';
|
||||
import { ADD_ONS } from './harvester-map';
|
||||
import { registerAddonSideNav } from '../utils/dynamic-nav';
|
||||
import { setStandalonePrime } from '../utils/l10n-globals';
|
||||
import { setVendor } from '@shell/config/private-label';
|
||||
import { createCssVars } from '@shell/utils/color';
|
||||
import { CONFIGURABLE_TABLES } from '@shell/store/features';
|
||||
import { tableViewsOffFeature } from '../utils/table-views';
|
||||
import { SETTING } from '@shell/config/settings';
|
||||
import { primeBrandUpdater } from '../utils/prime-brand';
|
||||
|
||||
const TEMPLATE = HCI.VM_VERSION;
|
||||
const MONITORING_GROUP = 'Monitoring & Logging::Monitoring';
|
||||
@@ -121,27 +125,9 @@ export function init($plugin, store) {
|
||||
const [isHarvesterPrime, isDark, privateLabel] = JSON.parse(value);
|
||||
const primeLogo = isDark ? require('../assets/images/dark/suse-virtualization.svg') : require('../assets/images/suse-virtualization.svg');
|
||||
|
||||
setStandalonePrime(isHarvesterPrime);
|
||||
setVendor(privateLabel || '');
|
||||
|
||||
let primaryColorStyle = document.getElementById('harvester-prime-primary-color');
|
||||
|
||||
// set harvester prime primary color style to #4dd192 (Cyan Green)
|
||||
if (isHarvesterPrime) {
|
||||
if (!primaryColorStyle) {
|
||||
primaryColorStyle = document.createElement('style');
|
||||
primaryColorStyle.id = 'harvester-prime-primary-color';
|
||||
document.head.appendChild(primaryColorStyle);
|
||||
primaryColorStyle.sheet.insertRule('body.theme-light, body.theme-dark {}', 0);
|
||||
}
|
||||
|
||||
const colors = createCssVars('#4dd192', isDark ? 'dark' : 'light');
|
||||
const rule = primaryColorStyle.sheet.cssRules[0];
|
||||
|
||||
Object.entries(colors).forEach(([property, color]) => rule.style.setProperty(property.trim(), color));
|
||||
} else {
|
||||
primaryColorStyle?.remove();
|
||||
}
|
||||
|
||||
const primeLabelKey = privateLabel ? 'harvester.branding.primeLabel' : 'harvester.branding.primeEmptyLabel';
|
||||
|
||||
store.dispatch('setIsSingleProduct', {
|
||||
@@ -156,6 +142,30 @@ export function init($plugin, store) {
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Keep the shell's table views off: they drop the filters the lists put in the table header
|
||||
store.watch(
|
||||
(_state, getters) => getters['management/byId'](MANAGEMENT.FEATURE, CONFIGURABLE_TABLES),
|
||||
(feature) => {
|
||||
if (!feature) {
|
||||
store.dispatch('management/load', { data: tableViewsOffFeature() });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const updateBrand = primeBrandUpdater();
|
||||
|
||||
// The value is read too: a change event from the server is loaded into the same setting object
|
||||
store.watch(
|
||||
(_state, getters) => {
|
||||
const brandSetting = getters['management/byId'](MANAGEMENT.SETTING, SETTING.BRAND);
|
||||
|
||||
return [!!getters['harvester-common/isHarvesterPrime'], brandSetting, brandSetting?.value];
|
||||
},
|
||||
([isHarvesterPrime, brandSetting]) => updateBrand(brandSetting, isHarvesterPrime),
|
||||
{ immediate: true }
|
||||
);
|
||||
}
|
||||
|
||||
product({
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { _CREATE, _EDIT, _VIEW } from '@shell/config/query-params';
|
||||
import YamlEditor from '@shell/components/YamlEditor';
|
||||
import { HCI } from '@pkg/harvester/config/labels-annotations';
|
||||
import HarvesterEditCloudTemplate from '../harvesterhci.io.cloudtemplate.vue';
|
||||
|
||||
const OLD_CONFIG = '#cloud-config\npassword: old\n';
|
||||
const NEW_CONFIG = '#cloud-config\npassword: new\n';
|
||||
|
||||
// A cloud-config template as the store gives it, `save` records what would be sent to the API
|
||||
function cloudTemplate({ isNew = false } = {}) {
|
||||
const value = {
|
||||
type: 'configmap',
|
||||
metadata: {
|
||||
name: 'tpl',
|
||||
namespace: 'default',
|
||||
...(isNew ? {} : { labels: { [HCI.CLOUD_INIT]: 'user' } }),
|
||||
},
|
||||
data: isNew ? undefined : { cloudInit: OLD_CONFIG },
|
||||
$dispatch: jest.fn(),
|
||||
save: () => Promise.resolve(),
|
||||
saved: [],
|
||||
};
|
||||
|
||||
jest.spyOn(value, 'save').mockImplementation(() => {
|
||||
value.saved.push(JSON.parse(JSON.stringify({ metadata: value.metadata, data: value.data })));
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// The real shell YamlEditor, with only its CodeMirror editor stubbed, so the events the form listens to are the
|
||||
// ones the shell emits
|
||||
function mountForm(mode, value) {
|
||||
return mount(HarvesterEditCloudTemplate, {
|
||||
props: { value, mode },
|
||||
global: {
|
||||
stubs: {
|
||||
CruResource: { template: '<div><slot /></div>' },
|
||||
Tabbed: { template: '<div><slot /></div>' },
|
||||
Tab: { template: '<div><slot /></div>' },
|
||||
NameNsDescription: true,
|
||||
LabeledSelect: true,
|
||||
CodeMirror: {
|
||||
name: 'CodeMirror', template: '<div />', emits: ['onInput', 'onReady', 'validationChanged']
|
||||
},
|
||||
FileDiff: true,
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'prefs/get': jest.fn(),
|
||||
'type-map/isSpoofed': () => false,
|
||||
currentStore: () => 'harvester',
|
||||
'harvester/schemaFor': () => ({ linkFor: () => '/v1/harvester/configmaps' }),
|
||||
'i18n/t': (key) => key,
|
||||
},
|
||||
},
|
||||
$router: { replace: jest.fn() },
|
||||
$route: {
|
||||
name: 'c-cluster-product-resource-id', params: {}, query: {}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// What a user typing in the editor does: CodeMirror reports the new text to the YamlEditor
|
||||
async function typeInEditor(wrapper, text) {
|
||||
wrapper.findComponent(YamlEditor).findComponent({ name: 'CodeMirror' }).vm.$emit('onInput', text);
|
||||
await wrapper.vm.$nextTick();
|
||||
}
|
||||
|
||||
describe('component: HarvesterEditCloudTemplate', () => {
|
||||
it('should save the edited cloud config when editing a template', async() => {
|
||||
const value = cloudTemplate();
|
||||
const wrapper = mountForm(_EDIT, value);
|
||||
|
||||
await typeInEditor(wrapper, NEW_CONFIG);
|
||||
await wrapper.vm.save(jest.fn());
|
||||
|
||||
expect(value.save).toHaveBeenCalledTimes(1);
|
||||
expect(value.saved[0].data).toStrictEqual({ cloudInit: NEW_CONFIG });
|
||||
});
|
||||
|
||||
it('should keep the template type label when editing a template', async() => {
|
||||
const value = cloudTemplate();
|
||||
const wrapper = mountForm(_EDIT, value);
|
||||
|
||||
await typeInEditor(wrapper, NEW_CONFIG);
|
||||
await wrapper.vm.save(jest.fn());
|
||||
|
||||
expect(value.saved[0].metadata.labels).toStrictEqual({ [HCI.CLOUD_INIT]: 'user' });
|
||||
});
|
||||
|
||||
it('should save the last text when the cloud config is edited several times', async() => {
|
||||
const value = cloudTemplate();
|
||||
const wrapper = mountForm(_EDIT, value);
|
||||
|
||||
await typeInEditor(wrapper, '#cloud-config\n');
|
||||
await typeInEditor(wrapper, NEW_CONFIG);
|
||||
await wrapper.vm.save(jest.fn());
|
||||
|
||||
expect(value.saved[0].data).toStrictEqual({ cloudInit: NEW_CONFIG });
|
||||
});
|
||||
|
||||
it('should save the cloud config unchanged when only saving a template', async() => {
|
||||
const value = cloudTemplate();
|
||||
const wrapper = mountForm(_EDIT, value);
|
||||
|
||||
await wrapper.vm.save(jest.fn());
|
||||
|
||||
expect(value.saved[0].data).toStrictEqual({ cloudInit: OLD_CONFIG });
|
||||
});
|
||||
|
||||
it('should save the cloud config and the template type when creating a template', async() => {
|
||||
const value = cloudTemplate({ isNew: true });
|
||||
const wrapper = mountForm(_CREATE, value);
|
||||
|
||||
await typeInEditor(wrapper, NEW_CONFIG);
|
||||
await wrapper.vm.save(jest.fn());
|
||||
|
||||
expect(value.save).toHaveBeenCalledWith({ url: '/v1/harvester/configmaps' });
|
||||
expect(value.saved[0]).toStrictEqual({
|
||||
metadata: {
|
||||
name: 'tpl', namespace: 'default', labels: { [HCI.CLOUD_INIT]: 'user' }
|
||||
},
|
||||
data: { cloudInit: NEW_CONFIG },
|
||||
});
|
||||
});
|
||||
|
||||
it('should show the template cloud config in the editor when viewing a template', () => {
|
||||
const value = cloudTemplate();
|
||||
const wrapper = mountForm(_VIEW, value);
|
||||
|
||||
expect(wrapper.findComponent(YamlEditor).props('value')).toBe(OLD_CONFIG);
|
||||
expect(value.data).toStrictEqual({ cloudInit: OLD_CONFIG });
|
||||
});
|
||||
});
|
||||
@@ -59,8 +59,8 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
update() {
|
||||
this.value.data = { cloudInit: this.config };
|
||||
update(config) {
|
||||
this.value.data = { cloudInit: config };
|
||||
},
|
||||
|
||||
updateBeforeSave() {
|
||||
@@ -119,7 +119,7 @@ export default {
|
||||
:mode="mode"
|
||||
class="yaml-editor"
|
||||
:editor-mode="mode === 'view' ? 'VIEW_CODE' : 'EDIT_CODE'"
|
||||
@onChanges="update"
|
||||
@update:value="update"
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
@@ -133,9 +133,7 @@ export default {
|
||||
:deep() .yaml-editor{
|
||||
flex: 1;
|
||||
min-height: $yaml-height;
|
||||
& .code-mirror .CodeMirror {
|
||||
position: initial;
|
||||
height: auto;
|
||||
& .code-mirror .cm-editor {
|
||||
min-height: $yaml-height;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,8 @@ export default {
|
||||
<div class="header mb-20">
|
||||
<FileSelector
|
||||
v-if="isCreate"
|
||||
class="btn btn-sm bg-primary mt-10"
|
||||
variant="primary"
|
||||
class="mt-10"
|
||||
:label="t('generic.readFromFile')"
|
||||
accept=".pub"
|
||||
@selected="onKeySelected"
|
||||
|
||||
@@ -16,6 +16,8 @@ import isEmpty from 'lodash/isEmpty';
|
||||
import jsyaml from 'js-yaml';
|
||||
import { createYaml } from '@shell/utils/create-yaml';
|
||||
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor';
|
||||
import { foldAllComments } from '@components/RcCodeMirror';
|
||||
import { unfoldCode } from '@codemirror/language';
|
||||
import { FLOW_TYPE } from '../config/harvester-map';
|
||||
|
||||
const LOGGING_EVENT = 'Logging/Event';
|
||||
@@ -172,10 +174,9 @@ export default {
|
||||
});
|
||||
}
|
||||
},
|
||||
onYamlEditorReady(cm) {
|
||||
cm.getMode().fold = 'yamlcomments';
|
||||
cm.execCommand('foldAll');
|
||||
cm.execCommand('unfold');
|
||||
onYamlEditorReady(view) {
|
||||
foldAllComments(view);
|
||||
unfoldCode(view);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -173,6 +173,7 @@ export default {
|
||||
|
||||
try {
|
||||
await proxyResource.save();
|
||||
proxyResource.notifyGeneratedName?.();
|
||||
buttonCb(true);
|
||||
|
||||
this.$router.push({
|
||||
|
||||
@@ -299,6 +299,7 @@ export default {
|
||||
|
||||
const res = await this.value.save();
|
||||
|
||||
res.notifyGeneratedName?.();
|
||||
res.uploadImage(file);
|
||||
|
||||
buttonCb(true);
|
||||
|
||||
@@ -172,6 +172,7 @@ export default {
|
||||
|
||||
try {
|
||||
await proxyResource.save();
|
||||
proxyResource.notifyGeneratedName?.();
|
||||
buttonCb(true);
|
||||
|
||||
this.$router.push({
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@ $yaml-height: 200px;
|
||||
flex: 1;
|
||||
min-height: $yaml-height;
|
||||
|
||||
& .code-mirror .CodeMirror {
|
||||
& .code-mirror .cm-editor {
|
||||
min-height: $yaml-height;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ $yaml-height: 350px;
|
||||
flex: 1;
|
||||
min-height: $yaml-height;
|
||||
|
||||
& .code-mirror .CodeMirror {
|
||||
& .code-mirror .cm-editor {
|
||||
min-height: $yaml-height;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -2,9 +2,23 @@ import { mount } from '@vue/test-utils';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
import HarvesterEditContainer from '../container.vue';
|
||||
|
||||
const mountOptions = () => ({
|
||||
props: { mode: _EDIT, value: {} },
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('component: HarvesterEditContainer', () => {
|
||||
it('should display all the inputs', () => {
|
||||
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
|
||||
const wrapper = mount(HarvesterEditContainer, mountOptions());
|
||||
|
||||
const inputWraps = wrapper.findAll('[data-testid^=input-hec-]');
|
||||
|
||||
@@ -15,7 +29,7 @@ describe('component: HarvesterEditContainer', () => {
|
||||
'name',
|
||||
'container',
|
||||
])('should emit an update on %p input', (field) => {
|
||||
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
|
||||
const wrapper = mount(HarvesterEditContainer, mountOptions());
|
||||
const input = wrapper.find(`[data-testid="input-hec-${ field }"]`).find('input');
|
||||
const newValue = 123;
|
||||
|
||||
@@ -28,7 +42,7 @@ describe('component: HarvesterEditContainer', () => {
|
||||
'type',
|
||||
'bus',
|
||||
])('should emit an update on %p selection change', async(field) => {
|
||||
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
|
||||
const wrapper = mount(HarvesterEditContainer, mountOptions());
|
||||
const select = wrapper.find(`[data-testid="input-hec-${ field }"]`);
|
||||
|
||||
select.find('button').trigger('click');
|
||||
|
||||
+36
-24
@@ -5,14 +5,17 @@ import HarvesterEditExisting from '../existing.vue';
|
||||
describe('component: HarvesterEditExisting', () => {
|
||||
it('should display all the inputs', () => {
|
||||
const wrapper = mount(HarvesterEditExisting, {
|
||||
propsData: {
|
||||
props: {
|
||||
mode: _EDIT, value: {}, rows: []
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,14 +30,17 @@ describe('component: HarvesterEditExisting', () => {
|
||||
'name',
|
||||
])('should emit an update on %p input', (field) => {
|
||||
const wrapper = mount(HarvesterEditExisting, {
|
||||
propsData: {
|
||||
props: {
|
||||
mode: _EDIT, value: {}, rows: []
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,14 +59,17 @@ describe('component: HarvesterEditExisting', () => {
|
||||
'size',
|
||||
])('should emit an update on %p input and blur', (field) => {
|
||||
const wrapper = mount(HarvesterEditExisting, {
|
||||
propsData: {
|
||||
props: {
|
||||
mode: _EDIT, value: {}, rows: []
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,14 +89,17 @@ describe('component: HarvesterEditExisting', () => {
|
||||
// 'bus', // disabled
|
||||
])('should emit an update on %p selection change', async(field) => {
|
||||
const wrapper = mount(HarvesterEditExisting, {
|
||||
propsData: {
|
||||
props: {
|
||||
mode: _EDIT, value: {}, rows: []
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': () => [],
|
||||
'i18n/t': jest.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
-36
@@ -2,23 +2,29 @@ import { mount } from '@vue/test-utils';
|
||||
import { _CREATE } from '@shell/config/query-params';
|
||||
import HarvesterEditVMImage from '../vmImage.vue';
|
||||
|
||||
// The form emits an update when it is created, so count only the updates that an interaction adds
|
||||
const updates = (wrapper) => wrapper.emitted('update')?.length ?? 0;
|
||||
|
||||
describe('component: HarvesterEditVMImage', () => {
|
||||
it('should display all the inputs', () => {
|
||||
const wrapper = mount(HarvesterEditVMImage, {
|
||||
propsData: {
|
||||
props: {
|
||||
mode: _CREATE,
|
||||
isVirtualType: false,
|
||||
newCreateId: '123',
|
||||
validateRequired: true,
|
||||
idx: 1
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -31,20 +37,23 @@ describe('component: HarvesterEditVMImage', () => {
|
||||
'name',
|
||||
])('should emit an update on %p input', (field) => {
|
||||
const wrapper = mount(HarvesterEditVMImage, {
|
||||
propsData: {
|
||||
props: {
|
||||
mode: _CREATE,
|
||||
isVirtualType: false,
|
||||
newCreateId: '123',
|
||||
validateRequired: true,
|
||||
idx: 1
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
const input = wrapper.find(`[data-testid="input-hevi-${ field }"]`).find('input');
|
||||
@@ -57,31 +66,38 @@ describe('component: HarvesterEditVMImage', () => {
|
||||
|
||||
it.each([
|
||||
'size',
|
||||
])('should emit an update on %p input blur', (field) => {
|
||||
])('should emit an update on %p input blur', async(field) => {
|
||||
const wrapper = mount(HarvesterEditVMImage, {
|
||||
propsData: {
|
||||
props: {
|
||||
mode: _CREATE,
|
||||
isVirtualType: false,
|
||||
newCreateId: '123',
|
||||
validateRequired: true,
|
||||
idx: 1
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
const input = wrapper.find(`[data-testid="input-hevi-${ field }"]`).find('input');
|
||||
const newValue = 123;
|
||||
|
||||
input.setValue(newValue);
|
||||
input.trigger('blur');
|
||||
// setting the value already emits an update, so the blur has to add one on top of that
|
||||
await input.setValue(newValue);
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
const afterSetValue = updates(wrapper);
|
||||
|
||||
await input.trigger('blur');
|
||||
|
||||
expect(updates(wrapper)).toBeGreaterThan(afterSetValue);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -90,20 +106,23 @@ describe('component: HarvesterEditVMImage', () => {
|
||||
'bus',
|
||||
])('should emit an update on %p selection change', async(field) => {
|
||||
const wrapper = mount(HarvesterEditVMImage, {
|
||||
propsData: {
|
||||
props: {
|
||||
mode: _CREATE,
|
||||
isVirtualType: false,
|
||||
newCreateId: '123',
|
||||
validateRequired: true,
|
||||
idx: 1
|
||||
},
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
const select = wrapper.find(`[data-testid="input-hevi-${ field }"]`);
|
||||
|
||||
+74
-37
@@ -2,18 +2,24 @@ import { mount } from '@vue/test-utils';
|
||||
import { _EDIT } from '@shell/config/query-params';
|
||||
import HarvesterEditVolume from '../volume.vue';
|
||||
|
||||
// The form emits an update when it is created, so count only the updates that an interaction adds
|
||||
const updates = (wrapper) => wrapper.emitted('update')?.length ?? 0;
|
||||
|
||||
describe('component: HarvesterEditVolume', () => {
|
||||
it('should display all the inputs', () => {
|
||||
const wrapper = mount(HarvesterEditVolume, {
|
||||
propsData: { validateRequired: true },
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
'i18n/exists': jest.fn(),
|
||||
}
|
||||
},
|
||||
props: { validateRequired: true },
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
'i18n/exists': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,29 +28,56 @@ describe('component: HarvesterEditVolume', () => {
|
||||
expect(inputWraps).toHaveLength(4);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'name',
|
||||
'size',
|
||||
])('should emit an update on %p input blur', (field) => {
|
||||
it('should emit an update on name input', async() => {
|
||||
const wrapper = mount(HarvesterEditVolume, {
|
||||
propsData: { mode: _EDIT, validateRequired: true },
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
'i18n/exists': jest.fn(),
|
||||
}
|
||||
},
|
||||
props: { mode: _EDIT, validateRequired: true },
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
'i18n/exists': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
const input = wrapper.find(`[data-testid="input-hev-${ field }"]`).find('input');
|
||||
const newValue = 123;
|
||||
const before = updates(wrapper);
|
||||
const input = wrapper.find('[data-testid="input-hev-name"]').find('input');
|
||||
|
||||
input.setValue(newValue);
|
||||
input.trigger('blur');
|
||||
await input.setValue(123);
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
expect(updates(wrapper)).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('should emit an update on size input blur', async() => {
|
||||
const wrapper = mount(HarvesterEditVolume, {
|
||||
props: { mode: _EDIT, validateRequired: true },
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
'i18n/exists': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
const input = wrapper.find('[data-testid="input-hev-size"]').find('input');
|
||||
|
||||
// setting the value already emits an update, so the blur has to add one on top of that
|
||||
await input.setValue(123);
|
||||
|
||||
const afterSetValue = updates(wrapper);
|
||||
|
||||
await input.trigger('blur');
|
||||
|
||||
expect(updates(wrapper)).toBeGreaterThan(afterSetValue);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -52,23 +85,27 @@ describe('component: HarvesterEditVolume', () => {
|
||||
'bus'
|
||||
])('should emit an update on %p selection change', async(field) => {
|
||||
const wrapper = mount(HarvesterEditVolume, {
|
||||
propsData: { mode: _EDIT, validateRequired: true },
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
'i18n/exists': jest.fn(),
|
||||
}
|
||||
},
|
||||
props: { mode: _EDIT, validateRequired: true },
|
||||
global: {
|
||||
mocks: {
|
||||
$store: {
|
||||
getters: {
|
||||
'harvester-common/getFeatureEnabled': () => true,
|
||||
'harvester/all': jest.fn(),
|
||||
'i18n/t': jest.fn(),
|
||||
'i18n/exists': jest.fn(),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
const before = updates(wrapper);
|
||||
const select = wrapper.find(`[data-testid="input-hev-${ field }"]`);
|
||||
|
||||
select.find('button').trigger('click');
|
||||
await wrapper.trigger('keydown.down');
|
||||
await wrapper.trigger('keydown.enter');
|
||||
|
||||
expect(wrapper.emitted('update')).toHaveLength(1);
|
||||
expect(updates(wrapper)).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -333,7 +333,7 @@ $xml-height: 350px;
|
||||
min-height: $xml-height;
|
||||
font-family: monospace;
|
||||
|
||||
& .code-mirror .CodeMirror {
|
||||
& .code-mirror .cm-editor {
|
||||
min-height: $xml-height;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import harvesterCommonStore from './store/harvester-common';
|
||||
import harvesterStore from './store/harvester-store';
|
||||
import customValidators from './validators';
|
||||
import { PRODUCT_NAME } from './config/harvester';
|
||||
import {
|
||||
hostResolvesL10nGlobals, resolveL10nGlobals, standaloneProductName, PRODUCT_NAME_GLOBAL
|
||||
} from './utils/l10n-globals';
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import './styles/vue-flow.scss';
|
||||
|
||||
@@ -17,6 +20,15 @@ export default function (plugin: IPlugin) {
|
||||
// Auto-import model, detail, edit from the folders
|
||||
importTypes(plugin);
|
||||
|
||||
if (isSingleVirtualCluster) {
|
||||
// Standalone: the server-flavor setting picks the product name, see config/harvester-cluster
|
||||
plugin.register('l10n-global', PRODUCT_NAME_GLOBAL, standaloneProductName);
|
||||
} else if (!hostResolvesL10nGlobals(plugin.environment?.version)) {
|
||||
// Hosts older than Rancher 2.16 would show `[[Harvester]]` literally, so resolve the tokens here.
|
||||
// Mutating the module in place also updates the copy that importTypes registered.
|
||||
resolveL10nGlobals(require('./l10n/en-us.yaml'));
|
||||
}
|
||||
|
||||
// Provide plugin metadata from package.json
|
||||
plugin.metadata = require('./package.json');
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ generic:
|
||||
|
||||
unsupported:
|
||||
serverVersion: 'Current version: <code>{serverVersion}</code>'
|
||||
description: Harvester ui only supports Harvester cluster version greater or equal to 1.3.0.
|
||||
learnMore: Learn more about Harvester from the <a target="_blank" href="https://harvesterhci.io/" rel="noopener noreferrer nofollow">Harvester Web Site</a> or read the <a target="_blank" href="https://docs.harvesterhci.io/" rel="noopener noreferrer nofollow">Harvester Docs</a>
|
||||
description: '[[Harvester]] ui only supports [[Harvester]] cluster version greater or equal to 1.3.0.'
|
||||
learnMore: Learn more about [[Harvester]] from the <a target="_blank" href="https://harvesterhci.io/" rel="noopener noreferrer nofollow">[[Harvester]] Web Site</a> or read the <a target="_blank" href="https://docs.harvesterhci.io/" rel="noopener noreferrer nofollow">[[Harvester]] Docs</a>
|
||||
|
||||
nav:
|
||||
group:
|
||||
@@ -87,10 +87,10 @@ harvester:
|
||||
primeLabel: '{vendor}'
|
||||
primeEmptyLabel: ''
|
||||
logos:
|
||||
tip: 'Upload a logo to replace the Harvester logo in the top-level navigation header. Image height should be 21 pixels with a max width of 200 pixels. Max file size is 20KB. Accepted formats: JPEG, PNG, SVG.'
|
||||
tip: 'Upload a logo to replace the [[Harvester]] logo in the top-level navigation header. Image height should be 21 pixels with a max width of 200 pixels. Max file size is 20KB. Accepted formats: JPEG, PNG, SVG.'
|
||||
favicon:
|
||||
tip: 'Upload an icon to replace the Harvester favicon in the browser tab. Max file size is 20KB'
|
||||
productLabel: 'Harvester'
|
||||
tip: 'Upload an icon to replace the [[Harvester]] favicon in the browser tab. Max file size is 20KB'
|
||||
productLabel: '[[Harvester]]'
|
||||
modal:
|
||||
backup:
|
||||
success: 'Backup { backUpName } has been initiated.'
|
||||
@@ -182,7 +182,7 @@ harvester:
|
||||
delete: Delete
|
||||
bundle:
|
||||
title: Generate a Support Bundle
|
||||
titleDescription: Collect system-related logs in Harvester to assist with troubleshooting and support.
|
||||
titleDescription: Collect system-related logs in [[Harvester]] to assist with troubleshooting and support.
|
||||
tip: Each field below specifies an aspect of the support bundle. For detailed explanations, please refer to the <a href="{doc}" target="_blank">documentation</a>.
|
||||
url: Issue URL
|
||||
description: Description
|
||||
@@ -335,7 +335,7 @@ harvester:
|
||||
attachedVM: Attached Virtual Machine
|
||||
cpuManager: CPU<br>Manager
|
||||
diskState: Disk
|
||||
routeConnectivityTooltip: Connectivity between the VM network and the management network, which the Harvester nodes are connected to.
|
||||
routeConnectivityTooltip: Connectivity between the VM network and the management network, which the [[Harvester]] nodes are connected to.
|
||||
fingerprint: Fingerprint
|
||||
value: Value
|
||||
actions: Actions
|
||||
@@ -500,7 +500,7 @@ harvester:
|
||||
aclEmptyError: The fields in subnet access control list rule can not be empty.
|
||||
dashboard:
|
||||
label: Dashboard
|
||||
header: "Harvester Cluster: {cluster}"
|
||||
header: "[[Harvester]] Cluster: {cluster}"
|
||||
hardwareResourceGauge:
|
||||
cpu: CPU
|
||||
memory: Memory
|
||||
@@ -837,7 +837,7 @@ harvester:
|
||||
collapse: Hide VM-wide high-performance options
|
||||
blockMultiQueue:
|
||||
label: Virtio Block Multi-Queue
|
||||
tip: Map disk I/O to multiple queues so it is processed across multiple CPUs. Applies to virtio disks and requires a fixed CPU allocation (Harvester always sets one).
|
||||
tip: Map disk I/O to multiple queues so it is processed across multiple CPUs. Applies to virtio disks and requires a fixed CPU allocation ([[Harvester]] always sets one).
|
||||
noVirtioTip: Add at least one disk on the virtio bus to enable Block Multi-Queue.
|
||||
ioThreadsPolicy:
|
||||
label: I/O Threads Policy
|
||||
@@ -931,7 +931,7 @@ harvester:
|
||||
displayNamePlaceholder: Virtual machine alias name
|
||||
displayName: Display Name
|
||||
filesystem:
|
||||
description: Harvester supports filesystem volumes for VM via virtiofs.
|
||||
description: '[[Harvester]] supports filesystem volumes for VM via virtiofs.'
|
||||
type: Filesystem Type
|
||||
volume: Volume
|
||||
resource: Resource
|
||||
@@ -1140,8 +1140,8 @@ harvester:
|
||||
selectExisting: Select Existing Image
|
||||
createRepository: Creating Upgrade Repository
|
||||
succeeded: Succeeded
|
||||
releaseTip: Please read the upgrade documentation carefully. You can view details on the <a href="{url}" target="_blank">Harvester Release Notes</a>.
|
||||
checkReady: I have read and understood the upgrade instructions related to this Harvester version.
|
||||
releaseTip: Please read the upgrade documentation carefully. You can view details on the <a href="{url}" target="_blank">[[Harvester]] Release Notes</a>.
|
||||
checkReady: I have read and understood the upgrade instructions related to this [[Harvester]] version.
|
||||
pending: Pending
|
||||
upload:
|
||||
duplicatedFile: The file you are trying to upload already exists.
|
||||
@@ -1150,7 +1150,7 @@ harvester:
|
||||
os: OS
|
||||
kubernetes: Kubernetes
|
||||
monitoringChart: Monitoring Chart
|
||||
harvesterChart: Harvester Chart
|
||||
harvesterChart: '[[Harvester]] Chart'
|
||||
success: Success
|
||||
fail: Fail
|
||||
ongoing: On-going
|
||||
@@ -1190,7 +1190,7 @@ harvester:
|
||||
maxFailure:
|
||||
label: Max Failure
|
||||
count: Count
|
||||
tooltip: Max number of consecutive failed backups that could be tolerated. If reach this threshold, Harvester controller will suspend the schedule job. This value should less than retain count
|
||||
tooltip: Max number of consecutive failed backups that could be tolerated. If reach this threshold, [[Harvester]] controller will suspend the schedule job. This value should less than retain count
|
||||
virtualMachine:
|
||||
title: Virtual Machine Name
|
||||
placeholder: Select a virtual machine
|
||||
@@ -1204,7 +1204,7 @@ harvester:
|
||||
title: Restore Virtual Machine
|
||||
refreshInterval:
|
||||
label: Refresh Interval
|
||||
tip: The interval for the backup target to sync data to harvester cluster. Set to 0 to disable refresh mechanism.
|
||||
tip: The interval for the backup target to sync data to [[Harvester]] cluster. Set to 0 to disable refresh mechanism.
|
||||
backupTargetTip: The endpoint used to access the backupstore. NFS and S3 are supported.
|
||||
message:
|
||||
noSetting:
|
||||
@@ -1223,7 +1223,7 @@ harvester:
|
||||
actionLabel: Test connection
|
||||
waitingLabel: Testing connection...
|
||||
successLabel: Connection tested
|
||||
successMessage: 'Harvester can successfully connect to the endpoint {endpoint}'
|
||||
successMessage: '[[Harvester]] can successfully connect to the endpoint {endpoint}'
|
||||
restore:
|
||||
backup: Backup
|
||||
deletePreviousVolumes: Delete Previous Volumes
|
||||
@@ -1434,7 +1434,7 @@ harvester:
|
||||
placeholder: Select a provider network
|
||||
networkPolicy:
|
||||
label: Policies
|
||||
banner: The network policies must be used for VMs attached to overlay networks. Please read the <a href="{url}" target="_blank">harvester document</a> how the network policy works.
|
||||
banner: The network policies must be used for VMs attached to overlay networks. Please read the <a href="{url}" target="_blank">[[Harvester]] documentation</a> on how network policies work.
|
||||
network:
|
||||
label: Virtual Machine Networks
|
||||
tabs:
|
||||
@@ -1543,7 +1543,7 @@ harvester:
|
||||
autoRotateRKE2Certs:
|
||||
expiringInHours: Expiring in
|
||||
httpProxy:
|
||||
warning: 'WARNING: <br/> If you configure "http-proxy" and "https-proxy", you must also put Harvester node's CIDR into "no-proxy", otherwise the Harvester cluster will be broken.'
|
||||
warning: 'WARNING: <br/> If you configure "http-proxy" and "https-proxy", you must also put [[Harvester]] node's CIDR into "no-proxy", otherwise the [[Harvester]] cluster will be broken.'
|
||||
supportBundleImage:
|
||||
imagePullPolicy: Pull Policy
|
||||
repo: Repository
|
||||
@@ -1595,7 +1595,7 @@ harvester:
|
||||
strategyType: Strategy Type
|
||||
concurrency: Concurrency
|
||||
harvesterMonitoring:
|
||||
label: Harvester Monitoring
|
||||
label: '[[Harvester]] Monitoring'
|
||||
section:
|
||||
prometheus: Prometheus
|
||||
prometheusNodeExporter: Prometheus Node Exporter
|
||||
@@ -1610,10 +1610,10 @@ harvester:
|
||||
url: URL
|
||||
insecureSkipTLSVerify: Insecure Skip TLS Verify
|
||||
tip:
|
||||
prefix: Harvester secures cluster registration via TLS by default. If opt out "Insecure Skip TLS Verify", you must provide custom CA certificates using the
|
||||
prefix: '[[Harvester]] secures cluster registration via TLS by default. If opt out "Insecure Skip TLS Verify", you must provide custom CA certificates using the'
|
||||
middle: 'additional-ca'
|
||||
suffix: setting.
|
||||
message: To completely unset the imported Harvester cluster, please also remove it on the Rancher Dashboard UI via the <code> Virtualization Management </code> page.
|
||||
message: To completely unset the imported [[Harvester]] cluster, please also remove it on the Rancher Dashboard UI via the <code> Virtualization Management </code> page.
|
||||
ntpServers:
|
||||
isNotIPV4: The address you entered is not IPv4 or host. Please enter a valid IPv4 address or a host address.
|
||||
isDuplicate: There are duplicate NTP server configurations.
|
||||
@@ -1643,19 +1643,19 @@ harvester:
|
||||
networkData: Network Data
|
||||
|
||||
support:
|
||||
title: Harvester Support
|
||||
title: '[[Harvester]] Support'
|
||||
kubeconfig:
|
||||
title: Download KubeConfig
|
||||
titleDescription: Download kubeconfig for debugging.
|
||||
internal:
|
||||
rancher:
|
||||
title: Access Embedded Rancher UI
|
||||
titleDescription: You can only use the embedded Rancher UI for debugging and validation purposes. For more information about how Harvester integrates with Rancher, see the <a target="_blank" href="{url}" rel="noopener noreferrer nofollow">documentation</a>.
|
||||
titleDescription: You can only use the embedded Rancher UI for debugging and validation purposes. For more information about how [[Harvester]] integrates with Rancher, see the <a target="_blank" href="{url}" rel="noopener noreferrer nofollow">documentation</a>.
|
||||
longhorn:
|
||||
title: Access Embedded Longhorn UI
|
||||
titleDescription: We only support to use the embedded Longhorn UI for debugging and validation purpose.
|
||||
community:
|
||||
learnMore: Find out more about SUSE Harvester support
|
||||
learnMore: Find out more about [[Harvester]] support
|
||||
pricing: Contact us for pricing
|
||||
|
||||
namespace:
|
||||
@@ -1875,16 +1875,16 @@ harvester:
|
||||
|
||||
addons:
|
||||
descriptions:
|
||||
'harvester-system/vm-import-controller': vm-import-controller is an add-on to help migrate virtual machine workloads from other source clusters to an existing Harvester cluster.
|
||||
'harvester-system/pcidevices-controller': pcidevices-controller is an add-on to help discover PCI devices for nodes in your cluster and allow users to prepare devices for PCI Passthrough, for use with Harvester virtual machine and guest clusters.
|
||||
'cattle-logging-system/rancher-logging': rancher-logging is an add-on to collect versatile logs, events, and audits from the Harvester cluster and route them to many kinds of servers based on flows.
|
||||
'harvester-system/vm-import-controller': vm-import-controller is an add-on to help migrate virtual machine workloads from other source clusters to an existing [[Harvester]] cluster.
|
||||
'harvester-system/pcidevices-controller': pcidevices-controller is an add-on to help discover PCI devices for nodes in your cluster and allow users to prepare devices for PCI Passthrough, for use with [[Harvester]] virtual machine and guest clusters.
|
||||
'cattle-logging-system/rancher-logging': rancher-logging is an add-on to collect versatile logs, events, and audits from the [[Harvester]] cluster and route them to many kinds of servers based on flows.
|
||||
'harvester-system/rancher-vcluster': rancher-vcluster deploys a virtual cluster (vcluster) with Rancher installed.
|
||||
'cattle-monitoring-system/rancher-monitoring': rancher-monitoring is an add-on that collects Harvester cluster and virtual machine metrics and allows you to view the metrics on an embedded dashboard and send alert(s) to remote servers.
|
||||
'vm-import-controller': vm-import-controller is an add-on to help migrate virtual machine workloads from other source clusters to an existing Harvester cluster.
|
||||
'pcidevices-controller': pcidevices-controller is an add-on to help discover PCI devices for nodes in your cluster and allow users to prepare devices for PCI Passthrough, for use with Harvester virtual machines and guest clusters.
|
||||
'nvidia-driver-toolkit': 'nvidia-driver-toolkit is an add-on to enable vGPU devices and assign them to Harvester virtual machines.'
|
||||
'rancher-logging': rancher-logging is an add-on to collect versatile logs, events, and audits from the Harvester cluster and route them to many kinds of servers based on flows.
|
||||
'rancher-monitoring': rancher-monitoring is an add-on to collect Harvester cluster and virtual machine metrics, view them on the embedded dashboard, and send alert(s) to remote servers.
|
||||
'cattle-monitoring-system/rancher-monitoring': rancher-monitoring is an add-on that collects [[Harvester]] cluster and virtual machine metrics and allows you to view the metrics on an embedded dashboard and send alert(s) to remote servers.
|
||||
'vm-import-controller': vm-import-controller is an add-on to help migrate virtual machine workloads from other source clusters to an existing [[Harvester]] cluster.
|
||||
'pcidevices-controller': pcidevices-controller is an add-on to help discover PCI devices for nodes in your cluster and allow users to prepare devices for PCI Passthrough, for use with [[Harvester]] virtual machines and guest clusters.
|
||||
'nvidia-driver-toolkit': 'nvidia-driver-toolkit is an add-on to enable vGPU devices and assign them to [[Harvester]] virtual machines.'
|
||||
'rancher-logging': rancher-logging is an add-on to collect versatile logs, events, and audits from the [[Harvester]] cluster and route them to many kinds of servers based on flows.
|
||||
'rancher-monitoring': rancher-monitoring is an add-on to collect [[Harvester]] cluster and virtual machine metrics, view them on the embedded dashboard, and send alert(s) to remote servers.
|
||||
'rancher-vcluster': rancher-vcluster deploys a virtual cluster (vcluster) with Rancher installed.
|
||||
'k3k': k3k is an add-on to deploy the k3k controller.
|
||||
'rancher-k3k': rancher-k3k deploys a k3k virtual cluster with Rancher installed.
|
||||
@@ -1953,7 +1953,7 @@ harvester:
|
||||
providerSelect: Provider
|
||||
createNew: Create new provider
|
||||
requirementsTitle: Requirements
|
||||
requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Harvester Node."
|
||||
requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from [[Harvester]] Node."
|
||||
name: Name
|
||||
urlLabel: vCenter/ESXi URL
|
||||
urlPlaceholder: "https://vcenter.example.com/sdk"
|
||||
@@ -2006,7 +2006,7 @@ harvester:
|
||||
datastore: Datastore
|
||||
dashboard:
|
||||
title: Virtual Machine Migration Overview
|
||||
description: "Migrate virtual machines from VMware vSphere to Harvester using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to Harvester."
|
||||
description: "Migrate virtual machines from VMware vSphere to [[Harvester]] using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to [[Harvester]]."
|
||||
createPlan: Create Migration
|
||||
tableTitle: Migrations
|
||||
columns:
|
||||
@@ -2026,22 +2026,22 @@ harvester:
|
||||
checkMigratedVm: Click to check migrated VM details
|
||||
configureMappings:
|
||||
title: Set Mappings
|
||||
description: Map VMware networks and datastores to Harvester and Longhorn target resources
|
||||
description: Map VMware networks and datastores to [[Harvester]] and Longhorn target resources
|
||||
save: Save Mappings and Continue
|
||||
noTemplate: Start from scratch
|
||||
removeMap: Remove mapping
|
||||
networkMapping:
|
||||
title: Network Mapping
|
||||
description: Map VMware port groups to Harvester networks
|
||||
placeholder: Choose a Harvester network
|
||||
description: Map VMware port groups to [[Harvester]] networks
|
||||
placeholder: Choose a [[Harvester]] network
|
||||
template: Use existing network mapping as template
|
||||
options:
|
||||
podNetworking: Management Network
|
||||
ignored: Ignored (No NIC will be created)
|
||||
storageMapping:
|
||||
title: Storage Mapping
|
||||
description: Map VMware datastores to Harvester storage classes
|
||||
placeholder: Choose a Harvester Storage Class
|
||||
description: Map VMware datastores to [[Harvester]] storage classes
|
||||
placeholder: Choose a [[Harvester]] Storage Class
|
||||
template: Use existing storage mapping as template
|
||||
storageDefaults:
|
||||
title: 'Storage defaults — {name}'
|
||||
@@ -2360,7 +2360,7 @@ harvester:
|
||||
vGpuDevices: vGPU Devices
|
||||
showMore: Show More
|
||||
parentSriov: Filter By Parent SR-IOV GPU
|
||||
noPermission: Please contact your system administrator to add Harvester add-ons first.
|
||||
noPermission: Please contact your system administrator to add [[Harvester]] add-ons first.
|
||||
goSetting:
|
||||
prefix: The nvidia-driver-toolkit add-on is not enabled, click
|
||||
middle: here
|
||||
@@ -2396,7 +2396,7 @@ harvester:
|
||||
|
||||
vgpu:
|
||||
label: vGPU Devices
|
||||
noPermission: Please contact system administrator to add Harvester add-ons first.
|
||||
noPermission: Please contact system administrator to add [[Harvester]] add-ons first.
|
||||
detachWarning:
|
||||
title: Cannot Disable vGPU
|
||||
message: Please detach the device from the VM and save it first before disabling this vGPU device.
|
||||
@@ -2417,7 +2417,7 @@ harvester:
|
||||
|
||||
usb:
|
||||
label: USB Devices
|
||||
noPermission: Please contact system admin to add Harvester add-ons first
|
||||
noPermission: Please contact system admin to add [[Harvester]] add-ons first
|
||||
goSetting:
|
||||
prefix: The pcidevices-controller add-on is not enabled, click
|
||||
middle: here
|
||||
@@ -2478,7 +2478,7 @@ typeDescription:
|
||||
# Should fit on one line.
|
||||
# If you link to anything external, it MUST have
|
||||
# target="_blank" rel="noopener noreferrer nofollow"
|
||||
harvester: "Branding allows administrators to globally re-brand the UI by customizing the Harvester product name, logos and color scheme."
|
||||
harvester: "Branding allows administrators to globally re-brand the UI by customizing the [[Harvester]] product name, logos and color scheme."
|
||||
|
||||
advancedSettings:
|
||||
tags:
|
||||
@@ -2493,19 +2493,19 @@ advancedSettings:
|
||||
descriptions:
|
||||
'harv-vlan': Default Network Interface name of the VLAN network.
|
||||
'harv-backup-target': Custom backup target to store virtual machine backups.
|
||||
'branding': Branding allows administrators to globally re-brand the UI by customizing the Harvester product name, logos, and color scheme.
|
||||
'branding': Branding allows administrators to globally re-brand the UI by customizing the [[Harvester]] product name, logos, and color scheme.
|
||||
'harv-csi-driver-config': Configure additional information for CSI drivers.
|
||||
'harv-csi-online-expand-validation': Allow online volume expansion for specific CSI drivers.
|
||||
'harv-containerd-registry': Containerd Registry Configuration to connect private registries.
|
||||
'harv-log-level': Configure Harvester server log level. Defaults to Info.
|
||||
'harv-server-version': Harvester server version.
|
||||
'harv-server-flavor': "Indicates the Harvester edition: Prime or Community."
|
||||
'harv-upgrade-checker-enabled': Specifies whether to enable Harvester upgrade check or not. Default is True.
|
||||
'harv-upgrade-checker-url': Default Harvester upgrade check url. Only used when the <code>upgrade-checker-enabled</code> is equal to True.
|
||||
'harv-ui-source': Configure how to load the UI source. The `External` option is used to load the UI from a ui-index URL, while the `Bundled` option loads the UI from the local Harvester server.
|
||||
'harv-log-level': Configure [[Harvester]] server log level. Defaults to Info.
|
||||
'harv-server-version': '[[Harvester]] server version.'
|
||||
'harv-server-flavor': "Indicates the [[Harvester]] edition: Prime or Community."
|
||||
'harv-upgrade-checker-enabled': Specifies whether to enable [[Harvester]] upgrade check or not. Default is True.
|
||||
'harv-upgrade-checker-url': Default [[Harvester]] upgrade check url. Only used when the <code>upgrade-checker-enabled</code> is equal to True.
|
||||
'harv-ui-source': Configure how to load the UI source. The `External` option is used to load the UI from a ui-index URL, while the `Bundled` option loads the UI from the local [[Harvester]] server.
|
||||
'harv-ui-index': 'HTML index location for the UI.'
|
||||
'harv-cluster-registration-url': Registration URL for multi-cluster management.
|
||||
'harv-http-proxy': 'HTTP proxy for Harvester to access external services.'
|
||||
'harv-http-proxy': 'HTTP proxy for [[Harvester]] to access external services.'
|
||||
'harv-additional-ca': 'Custom CA root certificates for TLS validation.'
|
||||
'harv-overcommit-config': 'Resource overcommit configuration.'
|
||||
'harv-support-bundle-timeout': 'Support bundle timeout configuration in minutes, use 0 to disable the timeout.'
|
||||
@@ -2517,13 +2517,13 @@ advancedSettings:
|
||||
'harv-storage-network': 'Longhorn storage-network setting.'
|
||||
'harv-rwx-network': 'Configure RWX network behavior for shared or dedicated storage network usage.'
|
||||
'harv-support-bundle-namespaces': Select additional namespaces to include in the support bundle.
|
||||
'harv-auto-disk-provision-paths': Specify the disks(using glob pattern) that Harvester will automatically add as virtual machine storage.
|
||||
'harv-auto-disk-provision-paths': Specify the disks(using glob pattern) that [[Harvester]] will automatically add as virtual machine storage.
|
||||
'harv-support-bundle-image': Support bundle image configuration. Find different versions in <a href="https://hub.docker.com/r/rancher/support-bundle-kit/tags" target="_blank">rancher/support-bundle-kit</a>.
|
||||
'harv-release-download-url': This setting allows you to configure the <code>upgrade release download</code> URL address. Harvester will get the ISO URL and checksum value from the (<code>$URL</code>/<code>$VERSION</code>/version.yaml) file hosted by the configured URL.
|
||||
'harv-release-download-url': This setting allows you to configure the <code>upgrade release download</code> URL address. [[Harvester]] will get the ISO URL and checksum value from the (<code>$URL</code>/<code>$VERSION</code>/version.yaml) file hosted by the configured URL.
|
||||
'harv-default-vm-termination-grace-period-seconds': Configure the virtual machine termination grace period for virtual machine stop.
|
||||
'harv-ntp-servers': Configure NTP server. You can configure multiple IPv4 addresses or host addresses.
|
||||
'harv-auto-rotate-rke2-certs': The certificate rotation mechanism relies on Rancher. Harvester will automatically update certificates generation to trigger rotation.
|
||||
'harv-kubeconfig-default-token-ttl-minutes': 'TTL (in minutes) applied on Harvester administration kubeconfig files. Default is 0, which means to never expire.'
|
||||
'harv-auto-rotate-rke2-certs': The certificate rotation mechanism relies on Rancher. [[Harvester]] will automatically update certificates generation to trigger rotation.
|
||||
'harv-kubeconfig-default-token-ttl-minutes': 'TTL (in minutes) applied on [[Harvester]] administration kubeconfig files. Default is 0, which means to never expire.'
|
||||
'harv-longhorn-v2-data-engine-enabled': 'Enable the Longhorn V2 data engine. Default is false. <ul><li>Changing this setting will restart RKE2 on all nodes. This will not affect running VM workloads.</li><li>If you see "not enough hugepages-2Mi capacity" errors when enabling this setting, wait a minute for the error to clear. If the error remains, reboot the affected node.</li></ul>'
|
||||
'harv-longhorn-v2-data-engine-hugepage-enabled': 'Enable hugepages when using the Longhorn V2 data engine. Default is true. Disabling hugepages reduces memory pressure on low-spec nodes and increases deployment flexibility. However, performance may be lower compared to running with hugepages.'
|
||||
'harv-longhorn-v2-data-engine-memory-size': 'Configure the amount of memory allocated to the SPDK target daemon when using the Longhorn V2 data engine. Default is 2048 MiB.'
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { reactive } from 'vue';
|
||||
import VirtVm from '../kubevirt.io.virtualmachine';
|
||||
import { POD, PVC } from '@shell/config/types';
|
||||
import { HCI } from '../../types';
|
||||
|
||||
// The Steve base model and the shell resource class pull in most of the dashboard, none of that is needed here
|
||||
jest.mock('../harvester', () => ({ __esModule: true, default: class {} }));
|
||||
jest.mock('@shell/plugins/dashboard-store/resource-class', () => ({ colorForState: (state) => (state === 'Running' ? 'text-success' : 'text-info') }));
|
||||
|
||||
const VM_ID = 'ns1/vm1';
|
||||
|
||||
/**
|
||||
* Create a VM model instance without the full Steve model plumbing. Only the store getters that the
|
||||
* VM model reads are mocked, backed by plain lists (like the real store, `all` returns the same array
|
||||
* that is mutated in place on updates).
|
||||
*
|
||||
* The model reads its store either through `$rootGetters` (`harvester/...`) or through `$getters`, the getters of the
|
||||
* store it lives in. Both are backed by the same data, so the tests do not depend on which of them the model uses.
|
||||
*/
|
||||
function createVm({
|
||||
vm = {}, vmi, pods = [], pvcs = [], restores = [], backups = [], claims = ['disk-pvc'], printableStatus
|
||||
} = {}) {
|
||||
const lists = reactive({
|
||||
[POD]: pods,
|
||||
[PVC]: pvcs,
|
||||
[HCI.RESTORE]: restores,
|
||||
[HCI.BACKUP]: backups,
|
||||
});
|
||||
const byId = { [HCI.VMI]: vmi ? [vmi] : [] };
|
||||
|
||||
const getters = {
|
||||
all: (type) => lists[type] || [],
|
||||
byId: (type, id) => (byId[type] || lists[type] || []).find((x) => x.id === id),
|
||||
schemaFor: () => undefined,
|
||||
};
|
||||
const rootGetters = {
|
||||
'harvester-common/getFeatureEnabled': () => false,
|
||||
currentProduct: { inStore: 'harvester' },
|
||||
'harvester/all': getters.all,
|
||||
'harvester/byId': getters.byId,
|
||||
};
|
||||
|
||||
const obj = Object.create(VirtVm.prototype);
|
||||
|
||||
Object.assign(obj, {
|
||||
id: VM_ID,
|
||||
type: HCI.VM,
|
||||
metadata: {
|
||||
name: 'vm1', namespace: 'ns1', annotations: {}
|
||||
},
|
||||
spec: {
|
||||
runStrategy: 'Always',
|
||||
template: {
|
||||
spec: {
|
||||
domain: { cpu: { cores: 1 }, resources: { limits: { memory: '1Gi' } } },
|
||||
volumes: claims.map((c) => ({ name: c, persistentVolumeClaim: { claimName: c } })),
|
||||
}
|
||||
}
|
||||
},
|
||||
status: {
|
||||
created: true, ready: true, printableStatus: printableStatus || 'Running', conditions: []
|
||||
},
|
||||
...vm,
|
||||
});
|
||||
Object.defineProperty(obj, '$rootGetters', { value: rootGetters });
|
||||
Object.defineProperty(obj, '$getters', { value: getters });
|
||||
Object.defineProperty(obj, 't', { value: (k) => k });
|
||||
|
||||
return { vm: obj, lists };
|
||||
}
|
||||
|
||||
const readyVmi = (extra = {}) => ({
|
||||
id: VM_ID,
|
||||
metadata: { name: 'vm1', namespace: 'ns1' },
|
||||
status: {
|
||||
phase: 'Running',
|
||||
conditions: [{ type: 'Ready', status: 'True' }],
|
||||
...extra
|
||||
}
|
||||
});
|
||||
|
||||
const pvc = (name, encrypted, ns = 'ns1') => ({
|
||||
id: `${ ns }/${ name }`,
|
||||
metadata: { name, namespace: ns },
|
||||
isEncrypted: encrypted,
|
||||
});
|
||||
|
||||
describe('class VirtVm', () => {
|
||||
describe('restoreResource', () => {
|
||||
it('should distinguish snapshots from backups and follow backup updates', () => {
|
||||
const { vm, lists } = createVm({
|
||||
vm: {
|
||||
metadata: {
|
||||
namespace: 'ns1', name: 'vm1', annotations: { 'restore.harvesterhci.io/name': 'r1' }
|
||||
}
|
||||
},
|
||||
restores: [{
|
||||
id: 'ns1/r1', isComplete: false, spec: { virtualMachineBackupNamespace: 'ns1', virtualMachineBackupName: 'b1' }
|
||||
}],
|
||||
backups: [
|
||||
{ id: 'ns2/b1', spec: { type: 'snapshot' } },
|
||||
{ id: 'ns1/b1', spec: { type: 'backup' } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(vm.restoreResource.fromSnapshot).toBe(false);
|
||||
expect(vm.restoreState).toBe(false);
|
||||
|
||||
lists[HCI.BACKUP][1].spec.type = 'snapshot';
|
||||
expect(vm.restoreResource.fromSnapshot).toBe(true);
|
||||
|
||||
lists[HCI.BACKUP].splice(1, 1);
|
||||
expect(vm.restoreResource.fromSnapshot).toBe(false);
|
||||
|
||||
lists[HCI.RESTORE][0].isComplete = true;
|
||||
expect(vm.restoreState).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptedVolumeType', () => {
|
||||
it('should be none without volumes', () => {
|
||||
const { vm } = createVm({ claims: [] });
|
||||
|
||||
expect(vm.encryptedVolumeType).toBe('none');
|
||||
});
|
||||
|
||||
it('should be none if no PVC is encrypted', () => {
|
||||
const { vm } = createVm({ pvcs: [pvc('disk-pvc', false)] });
|
||||
|
||||
expect(vm.encryptedVolumeType).toBe('none');
|
||||
});
|
||||
|
||||
it('should be all if every PVC is encrypted', () => {
|
||||
const { vm } = createVm({
|
||||
claims: ['a', 'b'],
|
||||
pvcs: [pvc('a', true), pvc('b', true), pvc('other', false)]
|
||||
});
|
||||
|
||||
expect(vm.encryptedVolumeType).toBe('all');
|
||||
});
|
||||
|
||||
it('should be partial if some PVCs are encrypted', () => {
|
||||
const { vm } = createVm({
|
||||
claims: ['a', 'b'],
|
||||
pvcs: [pvc('a', true), pvc('b', false)]
|
||||
});
|
||||
|
||||
expect(vm.encryptedVolumeType).toBe('partial');
|
||||
});
|
||||
|
||||
it('should follow PVCs that are added to the store later', () => {
|
||||
const { vm, lists } = createVm({ claims: ['a'], pvcs: [] });
|
||||
|
||||
expect(vm.encryptedVolumeType).toBe('none');
|
||||
|
||||
lists[PVC].push(pvc('a', true));
|
||||
|
||||
expect(vm.encryptedVolumeType).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('actualState', () => {
|
||||
it('should be Off if the VM is halted', () => {
|
||||
const { vm } = createVm({
|
||||
vm: {
|
||||
spec: { runStrategy: 'Halted', template: { spec: { volumes: [], domain: {} } } },
|
||||
status: { created: false, conditions: [] }
|
||||
}
|
||||
});
|
||||
|
||||
expect(vm.actualState).toBe('Off');
|
||||
});
|
||||
|
||||
it('should be Running if the VMI is running and ready', () => {
|
||||
const { vm } = createVm({ vmi: readyVmi() });
|
||||
|
||||
expect(vm.actualState).toBe('Running');
|
||||
});
|
||||
|
||||
it('should be Not Ready if the VMI is running but not ready', () => {
|
||||
const { vm } = createVm({ vmi: readyVmi({ conditions: [{ type: 'Ready', status: 'False' }] }) });
|
||||
|
||||
expect(vm.actualState).toBe('Not Ready');
|
||||
});
|
||||
|
||||
it('should be Paused if the VMI has the Paused condition', () => {
|
||||
const { vm } = createVm({ vmi: readyVmi({ conditions: [{ type: 'Ready', status: 'True' }, { type: 'Paused', status: 'True' }] }) });
|
||||
|
||||
expect(vm.actualState).toBe('Paused');
|
||||
});
|
||||
|
||||
it('should be Stopping if the VM is not expected to run anymore but the VMI exists', () => {
|
||||
const { vm } = createVm({
|
||||
vm: { spec: { runStrategy: 'Halted', template: { spec: { volumes: [], domain: {} } } } },
|
||||
vmi: readyVmi()
|
||||
});
|
||||
|
||||
expect(vm.actualState).toBe('Stopping');
|
||||
});
|
||||
|
||||
it('should be Starting while the launcher pod is not ready', () => {
|
||||
const { vm } = createVm({
|
||||
vmi: readyVmi({ phase: 'Scheduling', conditions: [] }),
|
||||
pods: [{
|
||||
metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] },
|
||||
getPodStatus: { status: 'POD_NOT_SCHEDULABLE' }
|
||||
}]
|
||||
});
|
||||
|
||||
expect(vm.actualState).toBe('Starting');
|
||||
});
|
||||
|
||||
it('should be Unschedulable if the VM is starting and has an unschedulable condition', () => {
|
||||
const { vm } = createVm({
|
||||
vmi: readyVmi({ phase: 'Pending', conditions: [] }),
|
||||
vm: { status: { created: true, conditions: [{ reason: 'Unschedulable', message: 'no node' }] } }
|
||||
});
|
||||
|
||||
expect(vm.actualState).toBe('Unschedulable');
|
||||
expect(vm.isUnschedulable.message).toBe('no node');
|
||||
});
|
||||
|
||||
it('should not be Unschedulable without an unschedulable condition', () => {
|
||||
const { vm } = createVm({ vmi: readyVmi() });
|
||||
|
||||
expect(vm.isUnschedulable).toBeNull();
|
||||
expect(vm.actualState).toBe('Running');
|
||||
});
|
||||
|
||||
it('should not be Unschedulable if the VM is neither starting nor stopping', () => {
|
||||
const { vm } = createVm({
|
||||
vm: {
|
||||
spec: { runStrategy: 'Halted', template: { spec: { volumes: [], domain: {} } } },
|
||||
status: { created: false, conditions: [{ reason: 'Unschedulable' }] }
|
||||
}
|
||||
});
|
||||
|
||||
expect(vm.isUnschedulable).toBeNull();
|
||||
expect(vm.actualState).toBe('Off');
|
||||
});
|
||||
|
||||
it('should be Terminating if the VM is being deleted', () => {
|
||||
const { vm } = createVm({
|
||||
vmi: readyVmi(),
|
||||
vm: {
|
||||
metadata: {
|
||||
name: 'vm1', namespace: 'ns1', annotations: {}, deletionTimestamp: 'now'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(vm.actualState).toBe('Terminating');
|
||||
});
|
||||
|
||||
it('should be VM error if the VM has a failure condition', () => {
|
||||
const { vm } = createVm({ vm: { status: { created: true, conditions: [{ type: 'Failure', message: 'boom' }] } } });
|
||||
|
||||
expect(vm.actualState).toBe('VM error');
|
||||
});
|
||||
|
||||
it('should be Restoring while a restore is in progress', () => {
|
||||
const { vm } = createVm({
|
||||
vmi: readyVmi(),
|
||||
vm: {
|
||||
metadata: {
|
||||
name: 'vm1', namespace: 'ns1', annotations: { 'restore.harvesterhci.io/name': 'r1' }
|
||||
}
|
||||
},
|
||||
restores: [{ id: 'ns1/r1', isComplete: false }]
|
||||
});
|
||||
|
||||
expect(vm.actualState).toBe('Restoring');
|
||||
});
|
||||
|
||||
it('should not be Restoring if the restore of the annotation does not exist', () => {
|
||||
const { vm } = createVm({
|
||||
vmi: readyVmi(),
|
||||
vm: {
|
||||
metadata: {
|
||||
name: 'vm1', namespace: 'ns1', annotations: { 'restore.harvesterhci.io/name': 'other' }
|
||||
}
|
||||
},
|
||||
restores: [{ id: 'ns1/r1', isComplete: false }]
|
||||
});
|
||||
|
||||
expect(vm.actualState).toBe('Running');
|
||||
});
|
||||
});
|
||||
|
||||
describe('podResource', () => {
|
||||
it('should find the launcher pod by owner name', () => {
|
||||
const pod = { metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] } };
|
||||
const { vm } = createVm({ vmi: readyVmi(), pods: [pod] });
|
||||
|
||||
expect(vm.podResource).toStrictEqual(pod);
|
||||
});
|
||||
|
||||
it('should return the last pod if several pods have the same owner', () => {
|
||||
// e.g. the source and the target pod while the VM migrates
|
||||
const source = {
|
||||
metadata: {
|
||||
name: 'launcher-source', namespace: 'ns1', ownerReferences: [{ name: 'vm1' }]
|
||||
}
|
||||
};
|
||||
const target = {
|
||||
metadata: {
|
||||
name: 'launcher-target', namespace: 'ns1', ownerReferences: [{ name: 'vm1' }]
|
||||
}
|
||||
};
|
||||
const { vm } = createVm({ vmi: readyVmi(), pods: [source, target] });
|
||||
|
||||
expect(vm.podResource.metadata.name).toBe('launcher-target');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRestartRequired', () => {
|
||||
it('should reflect the RestartRequired condition', () => {
|
||||
const { vm } = createVm({ vm: { status: { conditions: [{ type: 'RestartRequired', status: 'True' }] } } });
|
||||
|
||||
expect(vm.isRestartRequired).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { reactive } from 'vue';
|
||||
import VirtVmInstance from '../kubevirt.io.virtualmachineinstance';
|
||||
import { HCI } from '../../types';
|
||||
import { HCI as HCI_ANNOTATIONS } from '../../config/labels-annotations';
|
||||
|
||||
jest.mock('../harvester', () => ({ __esModule: true, default: class {} }));
|
||||
jest.mock('@shell/plugins/dashboard-store/resource-class', () => ({ colorForState: () => 'text-info' }));
|
||||
|
||||
function migration(namespace, timestamp, phase = 'Running') {
|
||||
return {
|
||||
metadata: { namespace, creationTimestamp: timestamp },
|
||||
spec: { vmiName: 'vm1' },
|
||||
status: { phase },
|
||||
};
|
||||
}
|
||||
|
||||
function createVmi(migrations) {
|
||||
const list = reactive(migrations);
|
||||
const vmi = Object.create(VirtVmInstance.prototype);
|
||||
|
||||
Object.assign(vmi, {
|
||||
metadata: {
|
||||
namespace: 'ns1',
|
||||
name: 'vm1',
|
||||
annotations: { [HCI_ANNOTATIONS.MIGRATION_STATE]: 'Migrating' },
|
||||
},
|
||||
$getters: { all: (type) => type === HCI.VMIM ? list : [] },
|
||||
$rootGetters: { 'harvester/all': (type) => type === HCI.VMIM ? list : [] },
|
||||
});
|
||||
|
||||
return { vmi, list };
|
||||
}
|
||||
|
||||
describe('class VirtVmInstance', () => {
|
||||
it('should follow additions, timestamp changes, phase changes and removals in the same list', () => {
|
||||
const { vmi, list } = createVmi([]);
|
||||
|
||||
expect(vmi.vmimResource).toStrictEqual([]);
|
||||
expect(vmi.migrationState).toBeNull();
|
||||
|
||||
list.push(migration('ns1', '2026-01-01T00:00:00Z'));
|
||||
expect(vmi.migrationState.status).toBe('Migrating');
|
||||
|
||||
list.push(migration('ns1', '2026-01-02T00:00:00Z', 'Failed'));
|
||||
expect(vmi.migrationState.status).toBe('Failed');
|
||||
|
||||
list[0].metadata.creationTimestamp = '2026-01-03T00:00:00Z';
|
||||
expect(vmi.vmimResource).toBe(list[0]);
|
||||
expect(vmi.migrationState.status).toBe('Migrating');
|
||||
|
||||
list[0].status.phase = 'Failed';
|
||||
expect(vmi.migrationState.status).toBe('Failed');
|
||||
|
||||
list.splice(0, 2);
|
||||
expect(vmi.migrationState).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -232,6 +232,7 @@ export default {
|
||||
this.imageValue.spec.targetStorageClassName = 'longhorn-static';
|
||||
|
||||
res = await this.imageValue.save();
|
||||
res.notifyGeneratedName?.();
|
||||
|
||||
this.value.spec.image = res.id;
|
||||
}
|
||||
@@ -253,6 +254,7 @@ export default {
|
||||
this.value.metadata.annotations = { [HCI_ANNOTATIONS.SKIP_SINGLE_REPLICA_DETACHED_VOL]: JSON.stringify(this.skipSingleReplicaDetachedVol) };
|
||||
}
|
||||
await this.value.save();
|
||||
this.value.notifyGeneratedName?.();
|
||||
this.done();
|
||||
buttonCb(true);
|
||||
} catch (e) {
|
||||
@@ -290,6 +292,7 @@ export default {
|
||||
|
||||
const res = await this.imageValue.save();
|
||||
|
||||
res.notifyGeneratedName?.();
|
||||
this.uploadImageId = res.id;
|
||||
this.uploadController = new AbortController();
|
||||
|
||||
|
||||
@@ -210,7 +210,8 @@ export default {
|
||||
<FileSelector
|
||||
:byte-limit="20000"
|
||||
:read-as-data-url="true"
|
||||
class="role-secondary"
|
||||
variant="secondary"
|
||||
size="large"
|
||||
:label="t('branding.logos.uploadLight')"
|
||||
:mode="mode"
|
||||
@error="setError"
|
||||
@@ -233,7 +234,8 @@ export default {
|
||||
<FileSelector
|
||||
:byte-limit="20000"
|
||||
:read-as-data-url="true"
|
||||
class="role-secondary"
|
||||
variant="secondary"
|
||||
size="large"
|
||||
:label="t('branding.logos.uploadDark')"
|
||||
:mode="mode"
|
||||
@error="setError"
|
||||
@@ -274,7 +276,8 @@ export default {
|
||||
<FileSelector
|
||||
:byte-limit="20000"
|
||||
:read-as-data-url="true"
|
||||
class="role-secondary"
|
||||
variant="secondary"
|
||||
size="large"
|
||||
:label="t('branding.favicon.upload')"
|
||||
:mode="mode"
|
||||
@error="setError"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
|
||||
import { currentRouter, currentRoute } from '@pkg/harvester/utils/router';
|
||||
import { decodeSecretValue } from '@pkg/harvester/utils/forklift';
|
||||
import { HCI } from '@pkg/harvester/types';
|
||||
import { preventImplicitSubmit } from '@pkg/harvester/utils/wizard';
|
||||
|
||||
const store = useStore();
|
||||
const route = currentRoute();
|
||||
@@ -258,6 +259,7 @@ init();
|
||||
:title="wizardTitle"
|
||||
finish-mode="finish"
|
||||
class="wizard"
|
||||
@keydown.enter.capture="preventImplicitSubmit"
|
||||
@cancel="onCancel"
|
||||
@finish="onFinish"
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ import ReviewMigrationStep from '@pkg/harvester/components/vm-migration/ReviewMi
|
||||
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
|
||||
import { currentRouter } from '@pkg/harvester/utils/router';
|
||||
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
|
||||
import { preventImplicitSubmit } from '@pkg/harvester/utils/wizard';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
@@ -269,6 +270,7 @@ const onCancel = () => {
|
||||
finish-mode="finish"
|
||||
finish-button-mode="createAndStart"
|
||||
class="wizard"
|
||||
@keydown.enter.capture="preventImplicitSubmit"
|
||||
@cancel="onCancel"
|
||||
@finish="onFinish"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { featureEnabled } from '../feature-flags';
|
||||
|
||||
describe('featureEnabled', () => {
|
||||
it('should enable a feature from the version it was released in', () => {
|
||||
expect(featureEnabled('hotplugNic', 'v1.7.0')).toBe(true);
|
||||
expect(featureEnabled('hotplugNic', 'v1.8.2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not enable a feature before the version it was released in', () => {
|
||||
expect(featureEnabled('hotplugNic', 'v1.6.1')).toBe(false);
|
||||
expect(featureEnabled('hotplugCdRom', 'v1.7.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should ignore pre-release suffixes of the server version', () => {
|
||||
expect(featureEnabled('hotplugCdRom', 'v1.8.0-rc1')).toBe(true);
|
||||
expect(featureEnabled('hotplugCdRom', 'v1.8.0-dev-20260101')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unknown features', () => {
|
||||
expect(featureEnabled('doesNotExist', 'v1.8.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not enable anything for versions that are no longer supported', () => {
|
||||
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(featureEnabled('cpuPinning', 'v1.2.0')).toBe(false);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return the same result for repeated calls with different arguments', () => {
|
||||
expect(featureEnabled('hotplugNic', 'v1.6.0')).toBe(false);
|
||||
expect(featureEnabled('hotplugNic', 'v1.7.0')).toBe(true);
|
||||
expect(featureEnabled('hotplugNic', 'v1.6.0')).toBe(false);
|
||||
expect(featureEnabled('hotplugNic', 'v1.7.0')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { load } from 'js-yaml';
|
||||
import { hostResolvesL10nGlobals, resolveL10nGlobals, setStandalonePrime, standaloneProductName } from '../l10n-globals';
|
||||
|
||||
describe('hostResolvesL10nGlobals', () => {
|
||||
it('should be true from Rancher 2.16', () => {
|
||||
expect(hostResolvesL10nGlobals('v2.16.0')).toBe(true);
|
||||
expect(hostResolvesL10nGlobals('v2.17.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be true for 2.16 head builds', () => {
|
||||
expect(hostResolvesL10nGlobals('v2.16.0-68ddd75-head')).toBe(true);
|
||||
expect(hostResolvesL10nGlobals('v2.16-136b11a0062247c1b28f107d82283bdb29bc07ae-head')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be false before Rancher 2.16', () => {
|
||||
expect(hostResolvesL10nGlobals('v2.15.1')).toBe(false);
|
||||
expect(hostResolvesL10nGlobals('v2.14.6')).toBe(false);
|
||||
});
|
||||
|
||||
it('should be false when the Rancher version is missing or unknown', () => {
|
||||
expect(hostResolvesL10nGlobals('')).toBe(false);
|
||||
expect(hostResolvesL10nGlobals(undefined)).toBe(false);
|
||||
expect(hostResolvesL10nGlobals('dev')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('standaloneProductName', () => {
|
||||
it('should be Harvester when the server flavor is not prime', () => {
|
||||
setStandalonePrime(false);
|
||||
|
||||
expect(standaloneProductName()).toBe('Harvester');
|
||||
});
|
||||
|
||||
it('should be SUSE Virtualization when the server flavor is prime', () => {
|
||||
setStandalonePrime(true);
|
||||
|
||||
expect(standaloneProductName()).toBe('SUSE Virtualization');
|
||||
setStandalonePrime(false);
|
||||
});
|
||||
|
||||
it('should go back to Harvester when the server flavor changes from prime', () => {
|
||||
setStandalonePrime(true);
|
||||
setStandalonePrime(false);
|
||||
|
||||
expect(standaloneProductName()).toBe('Harvester');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveL10nGlobals', () => {
|
||||
it('should replace tokens with their name when no value is given', () => {
|
||||
const translations = { support: { title: '[[Harvester]] Support' } };
|
||||
|
||||
resolveL10nGlobals(translations);
|
||||
|
||||
expect(translations).toStrictEqual({ support: { title: 'Harvester Support' } });
|
||||
});
|
||||
|
||||
it('should replace every token in a string with the given value', () => {
|
||||
const translations = { message: 'The [[Harvester]] UI only supports [[Harvester]] v1.3.0 and later' };
|
||||
|
||||
resolveL10nGlobals(translations, { Harvester: 'SUSE Virtualization' });
|
||||
|
||||
expect(translations.message).toBe('The SUSE Virtualization UI only supports SUSE Virtualization v1.3.0 and later');
|
||||
});
|
||||
|
||||
it('should keep an escaped token as a literal', () => {
|
||||
const translations = { message: 'Use \\[[Harvester]] in a string' };
|
||||
|
||||
resolveL10nGlobals(translations);
|
||||
|
||||
expect(translations.message).toBe('Use [[Harvester]] in a string');
|
||||
});
|
||||
|
||||
it('should leave strings without tokens and non-string values untouched', () => {
|
||||
const translations = {
|
||||
plain: 'harvester-seeder is an add-on',
|
||||
count: 3,
|
||||
nested: { link: '<a href="https://harvesterhci.io/">site</a>' },
|
||||
};
|
||||
|
||||
resolveL10nGlobals(translations);
|
||||
|
||||
expect(translations).toStrictEqual({
|
||||
plain: 'harvester-seeder is an add-on',
|
||||
count: 3,
|
||||
nested: { link: '<a href="https://harvesterhci.io/">site</a>' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should leave no token in the extension translations', () => {
|
||||
const translations = load(fs.readFileSync(path.join(__dirname, '../../l10n/en-us.yaml'), 'utf8'));
|
||||
|
||||
expect(JSON.stringify(translations)).toContain('[[Harvester]]');
|
||||
|
||||
resolveL10nGlobals(translations);
|
||||
|
||||
expect(JSON.stringify(translations)).not.toContain('[[');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { primeBrandUpdater } from '../prime-brand';
|
||||
|
||||
describe('primeBrandUpdater', () => {
|
||||
it('should switch the brand to suse on Prime', () => {
|
||||
const setting = { value: 'harvester' };
|
||||
|
||||
primeBrandUpdater()(setting, true);
|
||||
|
||||
expect(setting.value).toBe('suse');
|
||||
});
|
||||
|
||||
it('should keep the brand of the server when not on Prime', () => {
|
||||
const setting = { value: 'harvester' };
|
||||
|
||||
primeBrandUpdater()(setting, false);
|
||||
|
||||
expect(setting.value).toBe('harvester');
|
||||
});
|
||||
|
||||
it('should put the brand of the server back when the flavor stops being Prime', () => {
|
||||
const setting = { value: 'harvester' };
|
||||
const update = primeBrandUpdater();
|
||||
|
||||
update(setting, true);
|
||||
update(setting, false);
|
||||
|
||||
expect(setting.value).toBe('harvester');
|
||||
});
|
||||
|
||||
it('should switch the brand to suse again when the server sends its brand back on Prime', () => {
|
||||
const setting = { value: 'harvester' };
|
||||
const update = primeBrandUpdater();
|
||||
|
||||
update(setting, true);
|
||||
setting.value = 'harvester';
|
||||
update(setting, true);
|
||||
|
||||
expect(setting.value).toBe('suse');
|
||||
});
|
||||
|
||||
it('should keep an empty brand of the server when not on Prime', () => {
|
||||
const setting = { value: '' };
|
||||
const update = primeBrandUpdater();
|
||||
|
||||
update(setting, true);
|
||||
update(setting, false);
|
||||
|
||||
expect(setting.value).toBe('');
|
||||
});
|
||||
|
||||
it('should do nothing before the setting is loaded', () => {
|
||||
expect(() => primeBrandUpdater()(undefined, true)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { isValidCIDR } from '../regular';
|
||||
|
||||
describe('isValidCIDR', () => {
|
||||
it.each([
|
||||
'10.0.0.0/16',
|
||||
'192.168.1.0/24',
|
||||
'0.0.0.0/0',
|
||||
'255.255.255.255/32',
|
||||
'172.16.100.9/12',
|
||||
])('should accept the IPv4 CIDR %s', (cidr) => {
|
||||
expect(isValidCIDR(cidr)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a short form', '10.1/16'],
|
||||
['a single number', '10/8'],
|
||||
['an integer address', '167772160/8'],
|
||||
['a hex address', '0x0a.0.0.0/8'],
|
||||
['an extra part', '10.0.0.0/24/1'],
|
||||
['an IPv6 range', 'fd00::/64'],
|
||||
['an IPv4-mapped IPv6 range', '::ffff:10.0.0.0/120'],
|
||||
['a prefix over 32', '10.0.0.0/33'],
|
||||
['an octet over 255', '10.0.0.256/24'],
|
||||
['zero-padded octets', '010.001.000.000/16'],
|
||||
['a zero-padded octet', '192.168.001.0/24'],
|
||||
['a zero-padded prefix', '10.0.0.0/08'],
|
||||
['no prefix', '10.0.0.0'],
|
||||
['an empty value', ''],
|
||||
])('should reject %s (%s)', (_, cidr) => {
|
||||
expect(isValidCIDR(cidr)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import Feature from '@shell/models/management.cattle.io.feature';
|
||||
import { MANAGEMENT } from '@shell/config/types';
|
||||
import { CONFIGURABLE_TABLES } from '@shell/store/features';
|
||||
import { tableViewsOffFeature } from '../table-views';
|
||||
|
||||
describe('tableViewsOffFeature', () => {
|
||||
it('should be the table views feature of the management store', () => {
|
||||
const feature = tableViewsOffFeature();
|
||||
|
||||
expect(feature.type).toBe(MANAGEMENT.FEATURE);
|
||||
expect(feature.id).toBe(CONFIGURABLE_TABLES);
|
||||
});
|
||||
|
||||
it('should be read as disabled by the shell', () => {
|
||||
const feature = new Feature(tableViewsOffFeature(), {
|
||||
getters: {}, rootGetters: {}, dispatch: jest.fn(), rootState: {}
|
||||
});
|
||||
|
||||
expect(feature.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { preventImplicitSubmit } from '../wizard';
|
||||
|
||||
function keydown(target) {
|
||||
return { target, preventDefault: jest.fn() };
|
||||
}
|
||||
|
||||
describe('preventImplicitSubmit', () => {
|
||||
it('should stop Enter in a text field from submitting the wizard', () => {
|
||||
const event = keydown(document.createElement('input'));
|
||||
|
||||
preventImplicitSubmit(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should leave Enter in a dropdown alone, which uses it to pick an option', () => {
|
||||
const select = document.createElement('div');
|
||||
const input = document.createElement('input');
|
||||
|
||||
select.className = 'v-select';
|
||||
select.appendChild(input);
|
||||
|
||||
const event = keydown(input);
|
||||
|
||||
preventImplicitSubmit(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['textarea', 'button'])('should leave Enter on a %s alone', (tag) => {
|
||||
const event = keydown(document.createElement(tag));
|
||||
|
||||
preventImplicitSubmit(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import semver from 'semver';
|
||||
|
||||
// Rancher 2.16 resolves `[[name]]` tokens in translations from values registered as 'l10n-global'
|
||||
export const L10N_GLOBALS_MIN_RANCHER_VERSION = '2.16.0';
|
||||
|
||||
// Name of the product name token, `[[Harvester]]`, and its value on Prime
|
||||
export const PRODUCT_NAME_GLOBAL = 'Harvester';
|
||||
export const PRIME_PRODUCT_NAME = 'SUSE Virtualization';
|
||||
|
||||
const TOKEN = /(\\?)\[\[([\w-]+)\]\]/g;
|
||||
|
||||
// Standalone has no Rancher Prime, the server-flavor setting decides instead
|
||||
let standalonePrime = false;
|
||||
|
||||
export function setStandalonePrime(isPrime) {
|
||||
standalonePrime = !!isPrime;
|
||||
}
|
||||
|
||||
export function standaloneProductName() {
|
||||
return standalonePrime ? PRIME_PRODUCT_NAME : PRODUCT_NAME_GLOBAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the host Rancher resolves `[[name]]` tokens itself.
|
||||
* A missing or unknown version counts as an older host.
|
||||
*/
|
||||
export function hostResolvesL10nGlobals(version) {
|
||||
const v = semver.coerce(version);
|
||||
|
||||
return !!v && semver.gte(v, L10N_GLOBALS_MIN_RANCHER_VERSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace `[[name]]` tokens in every string of `translations`, in place, the same way Rancher 2.16 does:
|
||||
* a known name gets its value, an unknown one falls back to the name and `\[[name]]` stays a literal `[[name]]`
|
||||
*/
|
||||
export function resolveL10nGlobals(translations, values = {}) {
|
||||
Object.keys(translations || {}).forEach((key) => {
|
||||
const value = translations[key];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
translations[key] = value.replace(TOKEN, (_, escaped, name) => (escaped ? `[[${ name }]]` : (values[name] ?? name)));
|
||||
} else if (value && typeof value === 'object') {
|
||||
resolveL10nGlobals(value, values);
|
||||
}
|
||||
});
|
||||
|
||||
return translations;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BRAND } from '@shell/config/types';
|
||||
|
||||
/**
|
||||
* Standalone Prime takes the SUSE theme of Rancher Prime: the `ui-brand` setting decides the stylesheet the shell
|
||||
* loads, and only the `suse` brand has one. Returns an updater that switches the copy of the setting in the store,
|
||||
* the server keeps its value, and puts the server's brand back when the flavor stops being Prime.
|
||||
*/
|
||||
export function primeBrandUpdater() {
|
||||
let serverBrand;
|
||||
|
||||
return (brandSetting, isPrime) => {
|
||||
if (!brandSetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (brandSetting.value !== BRAND.SUSE) {
|
||||
serverBrand = brandSetting.value;
|
||||
}
|
||||
|
||||
const brand = isPrime ? BRAND.SUSE : (serverBrand ?? brandSetting.value);
|
||||
|
||||
if (brandSetting.value !== brand) {
|
||||
brandSetting.value = brand;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
|
||||
/**
|
||||
* IPv4 CIDR such as 10.0.0.0/16. Unlike the shell's isValidCIDR since 3.0.14, it rejects the short forms
|
||||
* (10.1/16), extra parts (10.0.0.0/24/1), zero-padded octets (010.0.0.0/8) and IPv6, which the network settings
|
||||
* don't support
|
||||
*/
|
||||
export function isValidCIDR(cidr) {
|
||||
return /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/.test(cidr || '');
|
||||
}
|
||||
|
||||
export function isValidMac(value) {
|
||||
return /^[A-Fa-f0-9]{2}(-[A-Fa-f0-9]{2}){5}$|^[A-Fa-f0-9]{2}(:[A-Fa-f0-9]{2}){5}$/.test(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MANAGEMENT } from '@shell/config/types';
|
||||
import { CONFIGURABLE_TABLES } from '@shell/store/features';
|
||||
|
||||
/**
|
||||
* The shell's table views feature (@rancher/shell 3.0.14), disabled, for the management store. Standalone Harvester
|
||||
* has no such feature on the server, so the shell uses its default, on, and every list gets the table views, which
|
||||
* drop the filters the lists put in the table header. Only kept in the store, nothing is saved.
|
||||
*/
|
||||
export function tableViewsOffFeature() {
|
||||
return {
|
||||
id: CONFIGURABLE_TABLES,
|
||||
type: MANAGEMENT.FEATURE,
|
||||
metadata: { name: CONFIGURABLE_TABLES },
|
||||
spec: { value: false },
|
||||
status: {
|
||||
default: true, dynamic: true, lockedValue: null
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Keydown (capture) handler for a wizard. The shell's Wizard renders its step headers as buttons without a type
|
||||
* since 3.0.14, so they are submit buttons: Enter in a text field submits the form by clicking the first one,
|
||||
* which jumps back to step 1. Dropdowns are left alone, they use Enter to pick an option.
|
||||
*/
|
||||
export function preventImplicitSubmit(event) {
|
||||
const target = event?.target;
|
||||
|
||||
if (target?.tagName === 'INPUT' && !target.closest('.v-select')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user