diff --git a/pkg/harvester/components/health/__tests__/health.test.js b/pkg/harvester/components/health/__tests__/health.test.js
new file mode 100644
index 00000000..7a406fff
--- /dev/null
+++ b/pkg/harvester/components/health/__tests__/health.test.js
@@ -0,0 +1,912 @@
+import { mount as vtuMount, flushPromises } from '@vue/test-utils';
+import { createStore } from 'vuex';
+import Banner from '@components/Banner/Banner.vue';
+import { BadgeState } from '@components/BadgeState';
+import { Checkbox } from '@components/Form/Checkbox';
+import { LabeledInput } from '@components/Form/LabeledInput';
+import { RadioGroup } from '@components/Form/Radio';
+import { RcCounterBadge, RcTag } from '@components/Pill';
+import { RcModal } from '@components/RcModal';
+import LabeledSelect from '@shell/components/form/LabeledSelect';
+import { HCI } from '../../../types';
+import { affectedResourceLabel, ignoreRuleName, ignoredCounts, totalErrorCount } from '../../../utils/component-health';
+import HealthSummaryTable from '../HealthSummaryTable.vue';
+import ComponentChecksTable from '../ComponentChecksTable.vue';
+import IgnoreRuleModal from '../IgnoreRuleModal.vue';
+
+jest.mock('focus-trap', () => ({
+ createFocusTrap: jest.fn(() => ({
+ activate: jest.fn(),
+ deactivate: jest.fn(),
+ })),
+}));
+
+const TYPES = {
+ healthSummaries: HCI.HEALTH_SUMMARY,
+ componentHealths: HCI.COMPONENT_HEALTH,
+ ignoreRules: HCI.HEALTH_IGNORE_RULE,
+};
+
+const saveRule = jest.fn();
+const newRule = { save: saveRule };
+const editedRule = { save: saveRule };
+const createRule = jest.fn(() => newRule);
+const cloneRule = jest.fn(() => editedRule);
+let store;
+
+// resources are fetched by the components themselves, so serve them from a mocked store
+const opts = (props = {}) => {
+ store = createStore({
+ getters: {
+ 'i18n/t': () => (k) => k,
+ 'i18n/exists': () => () => true,
+ 'prefs/get': () => () => false,
+ currentProduct: () => ({ inStore: 'harvester' }),
+ 'harvester/schemaFor': () => (type) => (props.noSchemaFor === type ? null : { collectionMethods: props.collectionMethods || ['GET', 'POST'] }),
+ 'type-map/labelFor': () => (schema, count) => `label of ${ count }`,
+ },
+ actions: {
+ 'growl/success': jest.fn(),
+ 'harvester/findAll': (_, { type }) => {
+ const key = Object.keys(TYPES).find((k) => TYPES[k] === type);
+
+ props.onFindAll?.(type);
+
+ return props.failing === key ? Promise.reject(new Error('failed')) : props[key] || [];
+ },
+ 'harvester/create': createRule,
+ 'harvester/clone': cloneRule,
+ }
+ });
+
+ return { global: { plugins: [store], stubs: { teleport: true } } };
+};
+
+const toggleExpand = jest.fn();
+
+// renders the summary rows with every sub-row open, and the checks of each sub-row
+const summaryTable = {
+ props: ['rows', 'headers'],
+ data() {
+ return { searchQuery: '', expanded: {} };
+ },
+ methods: { toggleExpand, isExpanded: () => false },
+ template: `
+
+
+
{{ rows.map(r => r.id + ":" + r.severityRank).join(",") }}
+
+
+
`,
+};
+
+const checksTable = {
+ props: ['rows', 'headers', 'rowActions'],
+ data() {
+ return { searchQuery: '' };
+ },
+ template: '{{ rows.map(r => r.id + ":" + r.severityRank).join(",") }}
',
+};
+
+const mountSummary = async(props) => {
+ const base = opts(props);
+ const w = vtuMount(HealthSummaryTable, { ...base, global: { ...base.global, stubs: { ...base.global.stubs, SortableTable: summaryTable } } });
+
+ await flushPromises();
+
+ return w;
+};
+
+const mountChecks = (props, stubs = {}, storeProps = {}) => {
+ const base = opts(storeProps);
+
+ return vtuMount(ComponentChecksTable, {
+ ...base,
+ props,
+ global: {
+ ...base.global,
+ stubs: {
+ ...base.global.stubs, SortableTable: checksTable, ...stubs
+ }
+ },
+ });
+};
+
+const summaryOf = (components) => ({ id: 's', status: { components } });
+const healthOf = (id, component, checks) => ({
+ id, metadata: { name: id, labels: { 'health.harvesterhci.io/component': component } }, status: { checks }
+});
+
+const CHECK = {
+ id: 'h/a',
+ component: 'x',
+ componentHealthName: 'h',
+ checkName: 'a',
+ affectedResources: { kind: 'VirtualMachine', names: { one: {}, two: {} } },
+};
+
+const IN_A_WEEK = new Date(Date.now() + (7 * 24 * 3600 * 1000)).toISOString();
+const LAST_WEEK = new Date(Date.now() - (7 * 24 * 3600 * 1000)).toISOString();
+
+// named the way the UI names the rule of a check, unless `extra` says otherwise
+const ruleOf = (spec = {}, extra = {}) => {
+ const fullSpec = {
+ componentHealth: 'h', checkKey: 'a', ...spec
+ };
+
+ return {
+ metadata: { name: ignoreRuleName(fullSpec.componentHealth, fullSpec.checkKey) },
+ spec: fullSpec,
+ canUpdate: true,
+ canDelete: true,
+ promptRemove: jest.fn(),
+ ...extra,
+ };
+};
+
+const actionsOf = (props = {}, storeProps = {}) => {
+ const w = mountChecks({ rows: [CHECK], ...props }, {}, storeProps);
+ const actions = w.findComponent(checksTable).props('rows')[0].availableActions;
+
+ return {
+ w,
+ actions,
+ keys: actions.map((action) => (action.divider ? '-' : action.action)),
+ invoke: (key) => actions.find((action) => action.action === key).invoke(),
+ };
+};
+
+const mountRuleModal = ({
+ rule, mode, save = () => {}, row = CHECK
+} = {}) => {
+ createRule.mockClear();
+ cloneRule.mockClear();
+ saveRule.mockReset().mockImplementation(save);
+ delete newRule.spec;
+ delete editedRule.spec;
+
+ const onClose = jest.fn();
+ const w = vtuMount(IgnoreRuleModal, {
+ ...opts(),
+ props: {
+ row, rule, mode, modal: { show: true, onClose }
+ },
+ });
+
+ return {
+ w,
+ onClose,
+ confirm: () => w.find('[data-testid="component-health-ignore-confirm"]'),
+ duration: () => w.findAllComponents(RadioGroup).find((group) => group.props('name') === 'ignore-duration'),
+ currentExpiry: () => w.find('[data-testid="component-health-ignore-current-expiry"]'),
+ newExpiry: () => w.find('[data-testid="component-health-ignore-new-expiry"]'),
+ warning: () => w.findAllComponents(Banner).find((banner) => banner.props('color') === 'warning'),
+ };
+};
+
+describe('health components', () => {
+ it('ranks the health summary rows by severity', async() => {
+ const w = await mountSummary({
+ healthSummaries: [{
+ id: 'a',
+ status: {
+ components: {
+ x: { errorCount: 1 }, y: {}, z: { warningCount: 2 }
+ }
+ }
+ }]
+ });
+
+ expect(w.find('.summary').text()).toBe('a/x:0,a/y:2,a/z:1');
+ });
+ it('opens a row from the component name, which takes the keyboard focus', async() => {
+ toggleExpand.mockClear();
+
+ const w = await mountSummary({ healthSummaries: [summaryOf({ x: {} })] });
+ const name = w.find('.name button');
+
+ expect(name.text()).toBe('x');
+ expect(name.attributes('aria-expanded')).toBe('false');
+
+ await name.trigger('click');
+
+ expect(toggleExpand.mock.calls.map(([row]) => row.component)).toStrictEqual(['x']);
+ });
+ it('shows the checks of a component in its sub-row', async() => {
+ const w = await mountSummary({
+ healthSummaries: [summaryOf({ x: { errorCount: 1 }, y: { warningCount: 1 } })],
+ componentHealths: [
+ healthOf('h1', 'x', { a: { severity: 'Error' } }),
+ healthOf('h2', 'y', { b: { severity: 'Warning' } }),
+ healthOf('h3', 'x', { c: { severity: 'Error' } }),
+ ],
+ });
+ const rowsOf = (component) => w.findAllComponents(ComponentChecksTable).find((c) => c.element.closest(`.sub-${ component }`)).props('rows').map((r) => r.id);
+
+ expect(rowsOf('x')).toStrictEqual(['h1/a', 'h3/c']);
+ expect(rowsOf('y')).toStrictEqual(['h2/b']);
+ });
+ it('ranks the checks Error, Warning, Info', async() => {
+ const w = await mountSummary({
+ healthSummaries: [summaryOf({ x: { errorCount: 1 } })],
+ componentHealths: [healthOf('h', 'x', {
+ a: { severity: 'Info' }, b: { severity: 'Error' }, c: { severity: 'Warning' }
+ })],
+ });
+
+ expect(w.findComponent(ComponentChecksTable).props('rows').map((r) => `${ r.id }:${ r.severityRank }`)).toStrictEqual(['h/a:2', 'h/b:0', 'h/c:1']);
+ });
+ it('gives what was typed in the outer table to the table of checks', async() => {
+ const w = await mountSummary({
+ healthSummaries: [summaryOf({ x: { errorCount: 1 } })],
+ componentHealths: [healthOf('h', 'x', { a: { severity: 'Error' } })],
+ });
+
+ await w.findComponent(summaryTable).setData({ searchQuery: 'warn' });
+
+ expect(w.findComponent(ComponentChecksTable).props('filterText')).toBe('warn');
+ });
+ it('lets the table of checks filter its own rows', async() => {
+ const w = mountChecks({ rows: [CHECK], filterText: 'warn' });
+ const table = w.findComponent(checksTable);
+
+ await flushPromises();
+
+ expect(table.vm.searchQuery).toBe('warn');
+
+ await w.setProps({ filterText: 'error' });
+
+ expect(table.vm.searchQuery).toBe('error');
+ expect(table.props('headers').filter((header) => header.search !== false).map((header) => header.search)).toStrictEqual(['filterText']);
+ });
+ it('calls a row with errors Error, and only its errors match that word', async() => {
+ const w = await mountSummary({
+ healthSummaries: [summaryOf({ x: { errorCount: 1 } })],
+ componentHealths: [healthOf('h', 'x', { a: { severity: 'Error' }, b: { severity: 'Warning' } })],
+ });
+ const checks = w.findComponent(ComponentChecksTable).props('rows');
+
+ expect(w.findComponent(summaryTable).props('rows')[0].severity).toBe('Error');
+ expect(checks.map((r) => r.filterText.includes('error'))).toStrictEqual([true, false]);
+ });
+ it('does not search the count columns', async() => {
+ const w = await mountSummary({ healthSummaries: [summaryOf({ x: {} })] });
+ const { headers } = w.findComponent(summaryTable).props();
+
+ expect(headers.filter((header) => header.search !== false).map((header) => header.name)).toStrictEqual(['severity', 'component']);
+ });
+ it('gives the table filter what a sub-row shows', async() => {
+ const w = await mountSummary({
+ healthSummaries: [summaryOf({ x: { warningCount: 1 } })],
+ componentHealths: [healthOf('h', 'x', {
+ a: {
+ severity: 'Warning',
+ message: 'Some Message',
+ affectedResources: {
+ apiVersion: 'kubevirt.io/v1', kind: 'VirtualMachine', names: { 'vm-shown': {}, 'vm-hidden': { ignored: true } }
+ },
+ }
+ })],
+ });
+ const filterText = () => w.findComponent(summaryTable).props('rows')[0].checksFilterText;
+
+ ['x', 'warning', 'a', 'some message', 'kubevirt.io/v1', 'virtualmachine', 'label of', 'vm-shown'].forEach((text) => expect(filterText()).toContain(text));
+ expect(filterText()).not.toContain('vm-hidden');
+
+ await w.findComponent(Checkbox).vm.$emit('update:value', true);
+
+ expect(filterText()).toContain('vm-hidden');
+ });
+ it('leaves the rows as they are when filtering', async() => {
+ toggleExpand.mockClear();
+
+ const w = await mountSummary({
+ healthSummaries: [summaryOf({ x: { errorCount: 1 }, y: { warningCount: 1 } })],
+ componentHealths: [healthOf('h1', 'x', { a: { severity: 'Error' } }), healthOf('h2', 'y', { b: { severity: 'Warning' } })],
+ });
+ const table = w.findComponent(summaryTable);
+
+ await table.setData({ searchQuery: 'warning' });
+ await table.setData({ searchQuery: '' });
+
+ expect(toggleExpand).not.toHaveBeenCalled();
+ expect(table.vm.expanded).toStrictEqual({});
+ });
+ it('expands and collapses all the rows', async() => {
+ const w = await mountSummary({ healthSummaries: [summaryOf({ x: {}, y: {} })] });
+ const table = w.findComponent(summaryTable);
+ const [expand, collapse] = w.findAll('.expand-actions button');
+
+ await expand.trigger('click');
+
+ expect(table.vm.expanded).toStrictEqual({ 's/x': true, 's/y': true });
+
+ await collapse.trigger('click');
+
+ expect(table.vm.expanded).toStrictEqual({});
+ });
+ it('only offers to expand and collapse when there are rows', async() => {
+ const w = await mountSummary({});
+
+ expect(w.find('.expand-actions').exists()).toBe(false);
+ });
+ it('only offers the ignored checks when there are some', async() => {
+ const w = await mountSummary({
+ healthSummaries: [summaryOf({ x: { warningCount: 1 } })],
+ componentHealths: [healthOf('h', 'x', { a: { severity: 'Error', ignored: true }, b: { severity: 'Warning' } })],
+ });
+ const shownRows = () => w.findComponent(ComponentChecksTable).props('rows').map((r) => r.id);
+
+ expect(shownRows()).toStrictEqual(['h/b']);
+ expect(w.text()).toContain('showIgnored');
+
+ await w.findComponent(Checkbox).vm.$emit('update:value', true);
+
+ expect(shownRows()).toStrictEqual(['h/a', 'h/b']);
+
+ const clean = await mountSummary({
+ healthSummaries: [summaryOf({ x: { warningCount: 1 } })],
+ componentHealths: [healthOf('h', 'x', { b: { severity: 'Warning' } })],
+ });
+
+ expect(clean.text()).not.toContain('showIgnored');
+ });
+ it('expands a long message', async() => {
+ const message = 'x'.repeat(150);
+ const table = { props: ['rows'], template: '
' };
+ const w = mountChecks({ rows: [{ ...CHECK, message }] }, { SortableTable: table });
+
+ expect(w.text()).not.toContain(message);
+
+ expect(w.text()).toContain('generic.showMore');
+
+ await w.find('button.variant-link').trigger('click');
+
+ expect(w.text()).toContain(message);
+ expect(w.text()).toContain('generic.showLess');
+ });
+ it('shows the resources as chips, under what the UI calls them and their full count', () => {
+ const table = { props: ['rows'], template: '
' };
+ const row = {
+ ...CHECK,
+ affectedCount: 7,
+ affectedResources: {
+ apiVersion: 'longhorn.io/v1beta2', kind: 'Volume', names: { one: { ignored: true, ignoredBy: 'r' }, two: {} }
+ }
+ };
+ const w = mountChecks({ rows: [row] }, { SortableTable: table });
+
+ expect(w.find('.affected-title .text-label').text()).toBe('label of 7');
+ expect(w.find('.affected-title .api-version').text()).toBe('longhorn.io/v1beta2');
+ expect(w.findComponent(RcCounterBadge).props('count')).toBe(7);
+ expect(w.findAllComponents(RcTag).map((tag) => tag.text())).toStrictEqual(['two']);
+ });
+ it('keeps the full count and says how many ignored resources it leaves out', () => {
+ const table = { props: ['rows'], template: '
' };
+ const row = {
+ ...CHECK, affectedCount: 2, affectedResources: { kind: 'Volume', names: { one: { ignored: true, ignoredBy: 'r' }, two: {} } }
+ };
+ const hidden = mountChecks({ rows: [row] }, { SortableTable: table });
+ const shown = mountChecks({ rows: [row], showIgnored: true }, { SortableTable: table });
+
+ expect(hidden.findComponent(RcCounterBadge).props('count')).toBe(2);
+ expect(hidden.find('.hidden-ignored').text()).toContain('ignore.plusIgnored');
+ expect(shown.findComponent(RcCounterBadge).props('count')).toBe(2);
+ expect(shown.find('.hidden-ignored').exists()).toBe(false);
+ });
+ it('counts what "Show ignored" brings back, each thing once', () => {
+ const check = (ignored, names) => ({ ignored, affectedResources: { names } });
+
+ expect(ignoredCounts([
+ check(true, { a: { ignored: true }, b: { ignored: true } }),
+ check(false, { c: { ignored: true }, d: {} }),
+ check(false, {
+ e: { ignored: true }, f: { ignored: true }, g: {}
+ }),
+ check(false, { h: {} }),
+ ])).toStrictEqual({ checks: 1, resources: 3 });
+ expect(ignoredCounts(undefined)).toStrictEqual({ checks: 0, resources: 0 });
+ });
+ it('names the rule of a check after it', () => {
+ expect(ignoreRuleName('harvester-controller-vmimage', 'ImageRetryLimitExceeded')).toBe('harvester-controller-vmimage.imageretrylimitexceeded');
+ });
+ it('turns what a name can not have in a check into hyphens', () => {
+ expect(ignoreRuleName('harvester-controller-schedulevmbackup', 'Proactive Schedule Suspend')).toBe('harvester-controller-schedulevmbackup.proactive-schedule-suspend');
+ expect(ignoreRuleName('h', ' Reach_Max Failure! ')).toBe('h.reach-max-failure');
+ });
+ it('tells the ignored resources apart, and all of them when the check is ignored', () => {
+ const table = { props: ['rows'], template: '
' };
+ const row = {
+ ...CHECK, severity: 'Error', affectedResources: { kind: 'Volume', names: { one: { ignored: true, ignoredBy: 'r' }, two: {} } }
+ };
+ const wholeRow = {
+ ...row, ignored: true, affectedResources: { kind: 'Volume', names: { one: {}, two: {} } }
+ };
+ const tags = (w) => w.findAllComponents(RcTag).map((tag) => ({
+ ignored: tag.classes().includes('ignored'), disabled: tag.props('disabled'), says: tag.text().includes('ignore.ignored')
+ }));
+ const partly = mountChecks({ rows: [row], showIgnored: true }, { SortableTable: table });
+ const whole = mountChecks({ rows: [wholeRow], showIgnored: true }, { SortableTable: table });
+
+ expect(tags(partly)).toStrictEqual([
+ {
+ ignored: true, disabled: true, says: true
+ },
+ {
+ ignored: false, disabled: false, says: false
+ },
+ ]);
+ expect(partly.findComponent(BadgeState).props('color')).toBe('bg-error');
+ expect(tags(whole)).toStrictEqual([
+ {
+ ignored: true, disabled: true, says: false
+ },
+ {
+ ignored: true, disabled: true, says: false
+ },
+ ]);
+ expect(whole.findComponent(BadgeState).props('color')).toBe('bg-error');
+ });
+ it('keeps the resources past the first ones behind a link', async() => {
+ const table = { props: ['rows'], template: '
' };
+ const names = Object.fromEntries(['a', 'b', 'c', 'd', 'e', 'f', 'g'].map((name) => [name, {}]));
+ const row = {
+ ...CHECK, affectedCount: 7, affectedResources: { kind: 'VirtualMachine', names }
+ };
+ const w = mountChecks({ rows: [row] }, { SortableTable: table });
+
+ expect(w.findAllComponents(RcTag)).toHaveLength(3);
+ expect(w.find('button.variant-link').text()).toContain('generic.plusMore');
+
+ await w.find('button.variant-link').trigger('click');
+
+ expect(w.findAllComponents(RcTag)).toHaveLength(7);
+ expect(w.find('button.variant-link').text()).toContain('generic.showLess');
+ });
+ it('adds up the errors of all the components, for the alert on the tab', () => {
+ const components = {
+ x: { errorCount: 2, warningCount: 5 }, y: { errorCount: 1 }, z: {}
+ };
+
+ expect(totalErrorCount([summaryOf(components)])).toBe(3);
+ expect(totalErrorCount([summaryOf({ x: { warningCount: 5 } })])).toBe(0);
+ expect(totalErrorCount([{ id: 'empty' }])).toBe(0);
+ expect(totalErrorCount(undefined)).toBe(0);
+ });
+ it('names the resources by their schema, or by their kind without one', () => {
+ const store = {
+ getters: {
+ 'harvester/schemaFor': (id) => (['longhorn.io.volume', 'node'].includes(id) ? { id } : null),
+ 'type-map/labelFor': (schema, count) => `${ schema.id } ${ count }`,
+ }
+ };
+ const label = (apiVersion, kind) => affectedResourceLabel({ affectedCount: 6, affectedResources: { apiVersion, kind } }, store);
+
+ expect(label('longhorn.io/v1beta2', 'Volume')).toBe('longhorn.io.volume 6');
+ expect(label('v1', 'Node')).toBe('node 6');
+ expect(label('harvesterhci.io/v1beta1', 'Upgrade')).toBe('Upgrade');
+ });
+ it('tells when a component has no checks, unless they could not be loaded', () => {
+ expect(mountChecks({ rows: [] }).text()).toContain('noIssues');
+ expect(mountChecks({ rows: [], loadFailed: true }).text()).toContain('checksNotLoaded');
+ });
+ it('still shows the checks when the rules can not be loaded, and says so', async() => {
+ const w = await mountSummary({
+ healthSummaries: [summaryOf({ x: { errorCount: 1 } })],
+ componentHealths: [healthOf('h', 'x', { a: { severity: 'Error' } })],
+ failing: 'ignoreRules',
+ });
+ const checks = w.findComponent(ComponentChecksTable);
+
+ expect(checks.props('rows').map((r) => r.id)).toStrictEqual(['h/a']);
+ expect(checks.props('loadFailed')).toBe(false);
+ expect(w.findAllComponents(Banner).map((banner) => banner.props('label'))).toStrictEqual(['%harvester.dashboard.sections.componentHealth.loadFailed.ignoreRules%']);
+ });
+ it('does not call a component without issues when its checks can not be loaded', async() => {
+ const w = await mountSummary({
+ healthSummaries: [summaryOf({ x: { errorCount: 1 } })],
+ failing: 'componentHealths',
+ });
+
+ expect(w.findComponent(ComponentChecksTable).props('loadFailed')).toBe(true);
+ expect(w.findAllComponents(Banner).map((banner) => banner.props('label'))).toStrictEqual(['%harvester.dashboard.sections.componentHealth.loadFailed.componentHealths%']);
+ });
+ it('does not ask for the rules without their schema', async() => {
+ const onFindAll = jest.fn();
+
+ await mountSummary({ noSchemaFor: HCI.HEALTH_IGNORE_RULE, onFindAll });
+
+ expect(onFindAll.mock.calls.map(([type]) => type)).toStrictEqual([HCI.HEALTH_SUMMARY, HCI.COMPONENT_HEALTH]);
+ });
+ it('marks a check and the resources that are ignored', () => {
+ const table = {
+ props: ['rows'],
+ template: '
',
+ };
+ const row = { ...CHECK, affectedResources: { kind: 'VirtualMachine', names: { one: { ignored: true, ignoredBy: 'r' }, two: {} } } };
+ const textOf = (props) => mountChecks({ showIgnored: true, ...props }, { SortableTable: table }).text().replace(/\s+/g, ' ');
+
+ expect(textOf({ rows: [row] })).toMatch(/one ?\(%harvester\.dashboard\.sections\.componentHealth\.ignore\.ignored%\) ?two/);
+ expect(textOf({ rows: [row] })).not.toContain('a (%harvester');
+ expect(textOf({ rows: [{ ...row, ignored: true }] })).toContain('a (%harvester.dashboard.sections.componentHealth.ignore.ignored%)');
+ });
+
+ describe('the actions of a check', () => {
+ it('offers to create a rule when the check has none from the UI', () => {
+ const elsewhere = ruleOf({}, { metadata: { name: 'ignore-x7k2p' } });
+ const { w, keys, invoke } = actionsOf({ ignoreRules: [ruleOf({ checkKey: 'b' }), ruleOf({ componentHealth: 'other' }), elsewhere] });
+
+ expect(keys).toStrictEqual(['create']);
+
+ invoke('create');
+
+ expect(w.emitted('open-rule')).toStrictEqual([[{
+ row: CHECK, rule: undefined, mode: 'create'
+ }]]);
+ });
+ it('offers to view, edit and delete the rule of the check, and only that one', () => {
+ const rule = ruleOf();
+ const elsewhere = ruleOf({}, { metadata: { name: 'ignore-x7k2p' } });
+ const { w, keys, invoke } = actionsOf({ ignoreRules: [elsewhere, ruleOf({ checkKey: 'b' }), rule] });
+
+ expect(keys).toStrictEqual(['view', 'edit', '-', 'delete']);
+
+ invoke('view');
+ invoke('edit');
+ invoke('delete');
+
+ expect(w.emitted('open-rule')).toStrictEqual([
+ [{
+ row: CHECK, rule, mode: 'view'
+ }],
+ [{
+ row: CHECK, rule, mode: 'edit'
+ }],
+ ]);
+ expect(rule.promptRemove).toHaveBeenCalledTimes(1);
+ expect(elsewhere.promptRemove).not.toHaveBeenCalled();
+ });
+ it('only offers what the user is allowed to do', () => {
+ const keysFor = (extra) => actionsOf({ ignoreRules: [ruleOf({}, extra)] }).keys;
+
+ expect(keysFor({ canDelete: false })).toStrictEqual(['view', 'edit']);
+ expect(keysFor({ canUpdate: false })).toStrictEqual(['view', '-', 'delete']);
+ expect(keysFor({ canUpdate: false, canDelete: false })).toStrictEqual(['view']);
+ expect(actionsOf({}, { collectionMethods: ['GET'] }).keys).toStrictEqual([]);
+ expect(actionsOf({}, { noSchemaFor: HCI.HEALTH_IGNORE_RULE }).keys).toStrictEqual([]);
+ });
+ it('leaves out the menu when no check has anything in it', () => {
+ const rowActions = (props, storeProps) => actionsOf(props, storeProps).w.findComponent(checksTable).props('rowActions');
+
+ expect(rowActions({}, { collectionMethods: ['GET'] })).toBe(false);
+ expect(rowActions({ ignoreRules: [ruleOf()] }, { collectionMethods: ['GET'] })).toBe(true);
+ expect(rowActions({})).toBe(true);
+ });
+ });
+
+ describe('creating a rule', () => {
+ it('starts the modal over each time it opens', async() => {
+ const w = await mountSummary({
+ healthSummaries: [summaryOf({ x: { errorCount: 1 } })],
+ componentHealths: [healthOf('h', 'x', { a: { severity: 'Error' } })],
+ });
+ const open = async() => {
+ await w.findComponent(ComponentChecksTable).vm.$emit('open-rule', { row: CHECK, mode: 'create' });
+ await flushPromises();
+ };
+
+ expect(w.findComponent(RcModal).exists()).toBe(false);
+
+ await open();
+ await w.findComponent(LabeledInput).vm.$emit('update:value', 'known issue');
+ await flushPromises();
+
+ expect(w.findComponent(LabeledInput).props('value')).toBe('known issue');
+
+ await w.findComponent(RcModal).vm.$emit('close');
+ await flushPromises();
+
+ expect(w.findComponent(RcModal).exists()).toBe(false);
+
+ await open();
+
+ expect(w.findComponent(LabeledInput).props('value')).toBe('');
+ });
+ it('ignores the selected resources for the chosen time', async() => {
+ const { w, onClose, confirm } = mountRuleModal();
+
+ await w.findComponent(LabeledSelect).vm.$emit('update:value', ['two']);
+ await w.findComponent(LabeledInput).vm.$emit('update:value', 'known issue');
+ await flushPromises();
+ await confirm().trigger('click');
+ await flushPromises();
+
+ expect(cloneRule).not.toHaveBeenCalled();
+ expect(newRule.spec).toStrictEqual({
+ componentHealth: 'h',
+ checkKey: 'a',
+ resources: ['two'],
+ expiresAt: expect.any(String),
+ reason: 'known issue',
+ });
+ expect(saveRule).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ it('names the rule after its check, so a second one for it is refused', async() => {
+ const { confirm } = mountRuleModal();
+
+ await confirm().trigger('click');
+ await flushPromises();
+
+ expect(createRule.mock.calls[0][1].metadata).toStrictEqual({ name: 'h.a', labels: { 'health.harvesterhci.io/source': 'user' } });
+ });
+ it('gives a check with spaces a valid name, and keeps the check as it is', async() => {
+ const { confirm } = mountRuleModal({ row: { ...CHECK, checkName: 'Proactive Schedule Suspend' } });
+
+ await confirm().trigger('click');
+ await flushPromises();
+
+ expect(createRule.mock.calls[0][1].metadata.name).toBe('h.proactive-schedule-suspend');
+ expect(newRule.spec.checkKey).toBe('Proactive Schedule Suspend');
+ });
+ it('says when the rule will expire, from 7 days on', async() => {
+ const {
+ confirm, duration, currentExpiry, newExpiry
+ } = mountRuleModal();
+
+ expect(currentExpiry().exists()).toBe(false);
+ expect(duration().props('value')).toBe('days7');
+ expect(newExpiry().text()).toContain('ignore.willExpireOn');
+
+ await duration().vm.$emit('update:value', 'forever');
+ await flushPromises();
+
+ expect(newExpiry().text()).toContain('ignore.willNeverExpire');
+ expect(confirm().attributes('disabled')).toBeUndefined();
+ });
+ it('ignores the entire check forever without listing its resources', async() => {
+ const { w, confirm } = mountRuleModal();
+ const [scope, duration] = w.findAllComponents(RadioGroup);
+
+ await scope.vm.$emit('update:value', 'check');
+ await duration.vm.$emit('update:value', 'forever');
+ await flushPromises();
+ await confirm().trigger('click');
+ await flushPromises();
+
+ expect(newRule.spec).toStrictEqual({ componentHealth: 'h', checkKey: 'a' });
+ });
+ it('only offers the entire check when the check lists no resources', () => {
+ const w = vtuMount(IgnoreRuleModal, {
+ ...opts(),
+ props: { row: { ...CHECK, affectedResources: undefined }, modal: { show: true, onClose: jest.fn() } },
+ });
+
+ expect(w.findAllComponents(RadioGroup)).toHaveLength(1);
+ expect(w.findComponent(LabeledSelect).exists()).toBe(false);
+ });
+ it('can not ignore without a resource selected', async() => {
+ const { w, confirm } = mountRuleModal();
+
+ expect(confirm().attributes('disabled')).toBeUndefined();
+
+ await w.findComponent(LabeledSelect).vm.$emit('update:value', []);
+ await flushPromises();
+
+ expect(confirm().attributes('disabled')).toBeDefined();
+ });
+ it('stays open and shows the error when saving fails', async() => {
+ const { w, onClose, confirm } = mountRuleModal({ save: () => Promise.reject(new Error('forbidden')) });
+
+ await confirm().trigger('click');
+ await flushPromises();
+
+ expect(w.text()).toContain('forbidden');
+ expect(onClose).not.toHaveBeenCalled();
+ expect(confirm().attributes('disabled')).toBeUndefined();
+ });
+ it('does not close while the rule is saved', async() => {
+ let finishSaving;
+
+ const { w, onClose, confirm } = mountRuleModal({
+ save: () => new Promise((resolve) => {
+ finishSaving = resolve;
+ })
+ });
+
+ await confirm().trigger('click');
+ await flushPromises();
+
+ expect(confirm().attributes('disabled')).toBeDefined();
+
+ await w.findComponent(RcModal).vm.$emit('close');
+
+ expect(onClose).not.toHaveBeenCalled();
+
+ finishSaving();
+ await flushPromises();
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('editing a rule', () => {
+ it('starts from what the rule has, with no new duration chosen', () => {
+ const rule = ruleOf({
+ resources: ['two'], expiresAt: IN_A_WEEK, reason: 'known issue'
+ });
+ const {
+ w, duration, currentExpiry, newExpiry
+ } = mountRuleModal({ rule, mode: 'edit' });
+ const [scope] = w.findAllComponents(RadioGroup);
+
+ expect(scope.props('value')).toBe('resources');
+ expect(w.findComponent(LabeledSelect).props('value')).toStrictEqual(['two']);
+ expect(currentExpiry().text()).toContain('ignore.expiresOn');
+ expect(duration().props('value')).toBeNull();
+ expect(duration().props('options').map((option) => option.value)).toStrictEqual(['days7', 'days30', 'forever']);
+ expect(newExpiry().exists()).toBe(false);
+ expect(w.findComponent(LabeledInput).props('value')).toBe('known issue');
+ });
+ it('can not save until something changes', async() => {
+ const { w, confirm, duration } = mountRuleModal({ rule: ruleOf(), mode: 'edit' });
+ const reason = (value) => w.findComponent(LabeledInput).vm.$emit('update:value', value);
+
+ expect(confirm().attributes('disabled')).toBeDefined();
+
+ await reason('known issue');
+ await flushPromises();
+
+ expect(confirm().attributes('disabled')).toBeUndefined();
+
+ await reason('');
+ await duration().vm.$emit('update:value', 'forever');
+ await flushPromises();
+
+ expect(confirm().attributes('disabled')).toBeDefined();
+
+ await duration().vm.$emit('update:value', 'days7');
+ await flushPromises();
+
+ expect(confirm().attributes('disabled')).toBeUndefined();
+ });
+ it('saves on a copy of the rule, with the expiry it has', async() => {
+ const rule = ruleOf({
+ resources: ['two'], expiresAt: IN_A_WEEK, reason: 'known issue'
+ });
+ const { w, onClose, confirm } = mountRuleModal({ rule, mode: 'edit' });
+
+ await w.findComponent(LabeledInput).vm.$emit('update:value', 'still known');
+ await flushPromises();
+ await confirm().trigger('click');
+ await flushPromises();
+
+ expect(createRule).not.toHaveBeenCalled();
+ expect(cloneRule.mock.calls[0][1]).toStrictEqual({ resource: rule });
+ expect(editedRule.spec).toStrictEqual({ ...rule.spec, reason: 'still known' });
+ expect(saveRule).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ it('takes the resources, the expiry and the reason off the rule', async() => {
+ const { w, confirm, duration } = mountRuleModal({
+ rule: ruleOf({
+ resources: ['two'], expiresAt: IN_A_WEEK, reason: 'known issue'
+ }),
+ mode: 'edit'
+ });
+ const [scope] = w.findAllComponents(RadioGroup);
+
+ await scope.vm.$emit('update:value', 'check');
+ await duration().vm.$emit('update:value', 'forever');
+ await w.findComponent(LabeledInput).vm.$emit('update:value', '');
+ await flushPromises();
+ await confirm().trigger('click');
+ await flushPromises();
+
+ expect(editedRule.spec).toStrictEqual({ componentHealth: 'h', checkKey: 'a' });
+ });
+ it('renews an expired rule with the duration chosen', async() => {
+ const {
+ confirm, duration, currentExpiry, newExpiry
+ } = mountRuleModal({ rule: ruleOf({ expiresAt: LAST_WEEK }), mode: 'edit' });
+
+ expect(currentExpiry().text()).toContain('ignore.expiredOn');
+
+ await duration().vm.$emit('update:value', 'days30');
+ await flushPromises();
+
+ expect(newExpiry().text()).toContain('ignore.willExpireOn');
+
+ await confirm().trigger('click');
+ await flushPromises();
+
+ expect(new Date(editedRule.spec.expiresAt).getTime()).toBeGreaterThan(new Date(IN_A_WEEK).getTime());
+ });
+ it('warns that an expired rule stays expired when only something else changes', async() => {
+ const { w, duration, warning } = mountRuleModal({ rule: ruleOf({ expiresAt: LAST_WEEK }), mode: 'edit' });
+
+ expect(warning()).toBeUndefined();
+
+ await w.findComponent(LabeledInput).vm.$emit('update:value', 'known issue');
+ await flushPromises();
+
+ expect(warning().props('label')).toContain('ignore.staysExpired');
+
+ await duration().vm.$emit('update:value', 'days7');
+ await flushPromises();
+
+ expect(warning()).toBeUndefined();
+ });
+ it('does not warn about a rule that has not expired', async() => {
+ const { w, warning } = mountRuleModal({ rule: ruleOf({ expiresAt: IN_A_WEEK }), mode: 'edit' });
+
+ await w.findComponent(LabeledInput).vm.$emit('update:value', 'known issue');
+ await flushPromises();
+
+ expect(warning()).toBeUndefined();
+ });
+ it('opens a rule without resources on the entire check, and selects them all when asked', async() => {
+ const {
+ w, confirm, duration, currentExpiry
+ } = mountRuleModal({ rule: ruleOf(), mode: 'edit' });
+ const [scope] = w.findAllComponents(RadioGroup);
+
+ expect(scope.props('value')).toBe('check');
+ expect(currentExpiry().text()).toContain('ignore.neverExpires');
+ expect(duration().props('value')).toBeNull();
+ expect(w.findComponent(LabeledSelect).exists()).toBe(false);
+
+ await scope.vm.$emit('update:value', 'resources');
+ await flushPromises();
+
+ expect(w.findComponent(LabeledSelect).props('value')).toStrictEqual(['one', 'two']);
+
+ await confirm().trigger('click');
+ await flushPromises();
+
+ expect(editedRule.spec.resources).toStrictEqual(['one', 'two']);
+ });
+ it('keeps a resource of the rule that is no longer affected', async() => {
+ const { w, confirm } = mountRuleModal({ rule: ruleOf({ resources: ['gone', 'two'] }), mode: 'edit' });
+ const select = w.findComponent(LabeledSelect);
+
+ expect(select.props('options')).toStrictEqual(['one', 'two', 'gone']);
+ expect(select.props('value')).toStrictEqual(['two', 'gone']);
+
+ await w.findComponent(LabeledInput).vm.$emit('update:value', 'known issue');
+ await flushPromises();
+ await confirm().trigger('click');
+ await flushPromises();
+
+ expect(editedRule.spec.resources).toStrictEqual(['two', 'gone']);
+ });
+ });
+
+ describe('viewing a rule', () => {
+ it('shows what the rule has without letting it change', async() => {
+ const {
+ w, onClose, confirm, duration, currentExpiry
+ } = mountRuleModal({
+ rule: ruleOf({
+ resources: ['two'], expiresAt: IN_A_WEEK, reason: 'known issue'
+ }),
+ mode: 'view'
+ });
+
+ [...w.findAllComponents(RadioGroup), w.findComponent(LabeledSelect), w.findComponent(LabeledInput)].forEach((field) => {
+ expect(field.props('mode')).toBe('view');
+ });
+ expect(currentExpiry().text()).toContain('ignore.expiresOn');
+ expect(duration()).toBeUndefined();
+ expect(w.findComponent(LabeledInput).props('value')).toBe('known issue');
+ expect(confirm().exists()).toBe(false);
+ expect(w.find('[data-testid="rc-modal-cancel"]').exists()).toBe(false);
+
+ await w.find('[data-testid="component-health-ignore-close"]').trigger('click');
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(cloneRule).not.toHaveBeenCalled();
+ expect(saveRule).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/pkg/harvester/utils/component-health.js b/pkg/harvester/utils/component-health.js
new file mode 100644
index 00000000..0b5e4a43
--- /dev/null
+++ b/pkg/harvester/utils/component-health.js
@@ -0,0 +1,108 @@
+export const COMPONENT_LABEL = 'health.harvesterhci.io/component';
+export const IGNORE_RULE_SOURCE_LABEL = 'health.harvesterhci.io/source';
+
+export const IGNORE_DURATION_DAYS = {
+ days7: 7,
+ days30: 30,
+ forever: 0,
+};
+
+export const AFFECTED_RESOURCES_TRUNCATE_COUNT = 3;
+export const MESSAGE_TRUNCATE_LENGTH = 100;
+
+export const SEVERITY_COLORS = {
+ Error: 'bg-error',
+ Warning: 'bg-warning',
+ Info: 'bg-info',
+ Healthy: 'bg-success',
+};
+
+export const HEALTH_SUMMARY_SEVERITY_RANK = {
+ Error: 0,
+ Warning: 1,
+ Healthy: 2,
+};
+
+export const COMPONENT_HEALTH_SEVERITIES = ['Error', 'Warning', 'Info'];
+
+export function severityColor(severity) {
+ return SEVERITY_COLORS[severity] || 'bg-info';
+}
+
+export function healthSummarySeverity(row) {
+ if (row.errorCount > 0) {
+ return 'Error';
+ }
+
+ if (row.warningCount > 0) {
+ return 'Warning';
+ }
+
+ return 'Healthy';
+}
+
+// the summaries already leave out what is ignored
+export function totalErrorCount(healthSummaries) {
+ return (healthSummaries || []).reduce((total, summary) => {
+ return Object.values(summary?.status?.components || {}).reduce((sum, counts) => sum + (counts?.errorCount || 0), total);
+ }, 0);
+}
+
+export function componentOf(componentHealth) {
+ return componentHealth?.metadata?.labels?.[COMPONENT_LABEL] || componentHealth?.metadata?.name;
+}
+
+export function allAffectedResourceNames(row) {
+ return Object.keys(row?.affectedResources?.names || {});
+}
+
+// the resources that no HealthIgnoreRule ignores
+export function activeAffectedResourceNames(row) {
+ const names = row?.affectedResources?.names || {};
+
+ return Object.keys(names).filter((name) => !names[name]?.ignored);
+}
+
+export function resourceIgnoredBy(row, name) {
+ const detail = row?.affectedResources?.names?.[name];
+
+ return detail?.ignored ? detail.ignoredBy : '';
+}
+
+export function ignoredResourceCount(row) {
+ return allAffectedResourceNames(row).length - activeAffectedResourceNames(row).length;
+}
+
+// what "Show ignored" brings back: the checks ignored as a whole, and the ignored resources of the other checks
+export function ignoredCounts(checks) {
+ return (checks || []).reduce((counts, check) => {
+ if (check.ignored) {
+ counts.checks += 1;
+ } else {
+ counts.resources += ignoredResourceCount(check);
+ }
+
+ return counts;
+ }, { checks: 0, resources: 0 });
+}
+
+// the UI keeps one rule per check under this name, so Kubernetes refuses a second one for the same check.
+// Some checks are named after a condition reason with spaces, like "Proactive Schedule Suspend", which a name can't have
+export function ignoreRuleName(componentHealthName, checkName) {
+ const check = checkName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+
+ return `${ componentHealthName }.${ check }`;
+}
+
+export function affectedResourceKind(row) {
+ return row?.affectedResources?.kind || '';
+}
+
+// what the UI calls that kind, in the singular or plural of how many there are
+export function affectedResourceLabel(row, store) {
+ const { apiVersion = '', kind = '' } = row?.affectedResources || {};
+ const group = apiVersion.includes('/') ? `${ apiVersion.split('/')[0] }.` : '';
+ const schema = store.getters['harvester/schemaFor'](`${ group }${ kind.toLowerCase() }`);
+
+ return schema ? store.getters['type-map/labelFor'](schema, row.affectedCount) : kind;
+}