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 commit 00584f1d19)

* 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 commit 71141b257a)
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 commit d6f6f7c54c)

* 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 commit 6e0e0237ef)

---------

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:
marcelofukumoto
2026-11-04 18:05:58 +08:00
committed by GitHub
co-authored by Volker Theile
parent 47d337aa7d
commit fcc8f27f40
52 changed files with 3601 additions and 1640 deletions
@@ -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();
});
});
+49
View File
@@ -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;
}
+26
View File
@@ -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;
}
};
}
+9
View File
@@ -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);
}
+19
View File
@@ -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
},
};
}
+12
View File
@@ -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();
}
}