diff --git a/pkg/harvester/components/health/ComponentChecksTable.vue b/pkg/harvester/components/health/ComponentChecksTable.vue
new file mode 100644
index 00000000..419d843a
--- /dev/null
+++ b/pkg/harvester/components/health/ComponentChecksTable.vue
@@ -0,0 +1,336 @@
+
+
+
+
+ {{ t(`harvester.dashboard.sections.componentHealth.${ loadFailed ? 'checksNotLoaded' : 'noIssues' }`) }}
+
+
+
+
+
+
+
+ {{ value }}
+
+ ({{ t('harvester.dashboard.sections.componentHealth.ignore.ignored') }})
+
+
+
+
+
+ {{ displayedMessage(row, value) }}
+
+ {{ expandedMessageIds.includes(row.id) ? t('generic.showLess') : t('generic.showMore') }}
+
+
+
+
+
+
+ {{ affectedResourceLabel(row, store) }}
+
+ {{ row.affectedResources.apiVersion }}
+
+
+
+ {{ name }}
+
+
+ ({{ t('harvester.dashboard.sections.componentHealth.ignore.ignored') }})
+
+
+
+
+ {{ expandedResourceIds.includes(row.id) ? t('generic.showLess') : t('generic.plusMore', { n: remainingAffectedResourcesCount(row) }) }}
+
+
+ {{ t('harvester.dashboard.sections.componentHealth.ignore.plusIgnored', { n: hiddenIgnoredCount(row) }) }}
+
+
+
+ {{ t('harvester.dashboard.sections.componentHealth.noAffectedResources') }}
+
+
+
+
+
+
diff --git a/pkg/harvester/components/health/HealthSummaryTable.vue b/pkg/harvester/components/health/HealthSummaryTable.vue
new file mode 100644
index 00000000..b0943caa
--- /dev/null
+++ b/pkg/harvester/components/health/HealthSummaryTable.vue
@@ -0,0 +1,342 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('harvester.dashboard.sections.healthSummary.expandAll') }}
+
+
+ {{ t('harvester.dashboard.sections.healthSummary.collapseAll') }}
+
+
+
+
+
+
+
+
+
+ {{ row.component }}
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+
+
+
diff --git a/pkg/harvester/components/health/IgnoreRuleModal.vue b/pkg/harvester/components/health/IgnoreRuleModal.vue
new file mode 100644
index 00000000..7f2f1263
--- /dev/null
+++ b/pkg/harvester/components/health/IgnoreRuleModal.vue
@@ -0,0 +1,320 @@
+
+
+
+
+
+
+
+
+ {{ t('harvester.dashboard.sections.componentHealth.ignore.scope') }}
+
+
+
+
+
+
+
+
+ {{ t('harvester.dashboard.sections.componentHealth.ignore.duration') }}
+
+
+ {{ currentExpiry }}
+
+
+
+
+ {{ newExpiry }}
+
+
+
+
+
+
+
+
+
+ {{ t('generic.close') }}
+
+
+
+
+ {{ t(rule ? 'generic.save' : 'generic.create') }}
+
+
+
+
+
+
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..450ff136
--- /dev/null
+++ b/pkg/harvester/components/health/__tests__/health.test.js
@@ -0,0 +1,897 @@
+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 = () => {} } = {}) => {
+ 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: CHECK, 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('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('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/config/feature-flags.js b/pkg/harvester/config/feature-flags.js
index c82f5b64..5725cea8 100644
--- a/pkg/harvester/config/feature-flags.js
+++ b/pkg/harvester/config/feature-flags.js
@@ -85,6 +85,7 @@ const FEATURE_FLAGS = {
'v1.10.0': [
'maintenanceModeConditions',
'maintenanceModeDrainTimeoutSetting',
+ 'componentHealth',
],
};
diff --git a/pkg/harvester/l10n/en-us.yaml b/pkg/harvester/l10n/en-us.yaml
index 2fce5798..fc375a75 100644
--- a/pkg/harvester/l10n/en-us.yaml
+++ b/pkg/harvester/l10n/en-us.yaml
@@ -512,6 +512,58 @@ harvester:
label: Events
vmMetrics:
label: Virtual Machine Metrics
+ healthSummary:
+ component: Component
+ severity: Severity
+ errorCount: Errors
+ warningCount: Warnings
+ expandAll: Expand All
+ collapseAll: Collapse All
+ componentHealth:
+ label: Components
+ errors: '{count, plural, =1 {1 error} other {# errors}}'
+ component: Component
+ check: Check
+ severity: Severity
+ message: Message
+ affectedCount: Affected Resources
+ noAffectedResources: No affected resources
+ noIssues: No issues detected
+ checksNotLoaded: Unable to load the checks
+ showIgnored: 'Show ignored ({counts})'
+ ignoredChecks: '{count, plural, =1 {1 check} other {# checks}}'
+ ignoredResources: '{count, plural, =1 {1 resource} other {# resources}}'
+ loadFailed:
+ healthSummaries: 'Unable to load the health summary: {error}'
+ componentHealths: 'Unable to load the checks: {error}'
+ ignoreRules: 'Unable to load the ignore rules: {error}'
+ ignore:
+ create: Create Ignore Rule
+ view: View Ignore Rule
+ edit: Edit Ignore Rule
+ delete: Delete Ignore Rule
+ ignored: Ignored
+ plusIgnored: '+ {n} ignored'
+ scope: Scope
+ scopeResources: Selected resources
+ scopeCheck: Entire check
+ resources: Resources
+ duration: Ignore for
+ days7: 7 days
+ days30: 30 days
+ forever: Forever
+ expiresOn: 'Expires on {date}'
+ expiredOn: 'Expired on {date}'
+ neverExpires: Never expires
+ willExpireOn: 'Will expire on {date}'
+ willNeverExpire: Will never expire
+ staysExpired: 'This rule expired on {date} and stays expired unless you choose how long to ignore for.'
+ reason: Reason
+ reasonPlaceholder: Why is it ignored? (optional)
+ created: The ignore rule of {name} is created
+ updated: The ignore rule of {name} is updated
+ attemptingToDelete: You are attempting to delete the ignore rule of
+ reportedAgain: What it ignores will be reported again.
version: Version
host:
diff --git a/pkg/harvester/list/harvesterhci.io.dashboard.vue b/pkg/harvester/list/harvesterhci.io.dashboard.vue
index 9afc7432..894dec0b 100644
--- a/pkg/harvester/list/harvesterhci.io.dashboard.vue
+++ b/pkg/harvester/list/harvesterhci.io.dashboard.vue
@@ -24,8 +24,10 @@ import { allDashboardsExist } from '@shell/utils/grafana';
import { isEmpty } from '@shell/utils/object';
import { HCI } from '../types';
import HarvesterUpgrade from '../components/HarvesterUpgrade';
+import HealthSummaryTable from '../components/health/HealthSummaryTable';
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../config/harvester';
import { UNIT_SUFFIX } from '../utils/unit';
+import { totalErrorCount } from '../utils/component-health';
dayjs.extend(utc);
dayjs.extend(minMax);
@@ -125,6 +127,7 @@ export default {
DashboardMetrics,
Banner,
MessageLink,
+ HealthSummaryTable,
},
async fetch() {
@@ -237,6 +240,20 @@ export default {
computed: {
...mapGetters(['currentCluster']),
+ // the health resources only exist while the resource health addon is installed
+ componentHealthEnabled() {
+ const inStore = this.$store.getters['currentProduct'].inStore;
+
+ return this.$store.getters['harvester-common/getFeatureEnabled']('componentHealth') &&
+ !!this.$store.getters[`${ inStore }/schemaFor`](HCI.HEALTH_SUMMARY);
+ },
+
+ componentErrorCount() {
+ const inStore = this.$store.getters['currentProduct'].inStore;
+
+ return totalErrorCount(this.$store.getters[`${ inStore }/all`](HCI.HEALTH_SUMMARY));
+ },
+
accessibleResources() {
const inStore = this.$store.getters['currentProduct'].inStore;
@@ -821,6 +838,16 @@ export default {
+
+
+
diff --git a/pkg/harvester/promptRemove/__tests__/health.harvesterhci.io.healthignorerule.test.js b/pkg/harvester/promptRemove/__tests__/health.harvesterhci.io.healthignorerule.test.js
new file mode 100644
index 00000000..1babc306
--- /dev/null
+++ b/pkg/harvester/promptRemove/__tests__/health.harvesterhci.io.healthignorerule.test.js
@@ -0,0 +1,49 @@
+import { mount, flushPromises } from '@vue/test-utils';
+import Banner from '@components/Banner/Banner.vue';
+import PromptRemoveIgnoreRule from '../health.harvesterhci.io.healthignorerule.vue';
+
+function ruleOf(checkKey, remove = () => {}) {
+ return { spec: { componentHealth: 'h', checkKey }, remove: jest.fn(remove) };
+}
+
+function mountPrompt(value) {
+ const close = jest.fn();
+ const prompt = mount(PromptRemoveIgnoreRule, {
+ props: { value, close },
+ global: { mocks: { $store: { getters: { 'i18n/t': (key) => key } } } },
+ });
+
+ return { prompt, close };
+}
+
+describe('promptRemove: health.harvesterhci.io.healthignorerule', () => {
+ it('names the check the rule is for, not the rule', () => {
+ const { prompt } = mountPrompt([ruleOf('ImageRetryLimitExceeded')]);
+
+ expect(prompt.find('b').text()).toBe('ImageRetryLimitExceeded');
+ });
+
+ it('deletes the rule and closes', async() => {
+ const rule = ruleOf('a');
+ const { prompt, close } = mountPrompt([rule]);
+ const buttonDone = jest.fn();
+
+ await prompt.vm.remove(buttonDone);
+
+ expect(rule.remove).toHaveBeenCalledTimes(1);
+ expect(close).toHaveBeenCalledWith(buttonDone);
+ expect(buttonDone).not.toHaveBeenCalled();
+ });
+
+ it('shows why the rule could not be deleted and stays open', async() => {
+ const { prompt, close } = mountPrompt([ruleOf('a', () => Promise.reject(new Error('forbidden')))]);
+ const buttonDone = jest.fn();
+
+ await prompt.vm.remove(buttonDone);
+ await flushPromises();
+
+ expect(prompt.findComponent(Banner).text()).toContain('forbidden');
+ expect(close).not.toHaveBeenCalled();
+ expect(buttonDone).toHaveBeenCalledWith(false);
+ });
+});
diff --git a/pkg/harvester/promptRemove/health.harvesterhci.io.healthignorerule.vue b/pkg/harvester/promptRemove/health.harvesterhci.io.healthignorerule.vue
new file mode 100644
index 00000000..e772f8e9
--- /dev/null
+++ b/pkg/harvester/promptRemove/health.harvesterhci.io.healthignorerule.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+ {{ t('harvester.dashboard.sections.componentHealth.ignore.attemptingToDelete') }} {{ checkNames }}. {{ t('harvester.dashboard.sections.componentHealth.ignore.reportedAgain') }}
+
+
+
+
diff --git a/pkg/harvester/types.ts b/pkg/harvester/types.ts
index 5193beae..b860c1de 100644
--- a/pkg/harvester/types.ts
+++ b/pkg/harvester/types.ts
@@ -37,6 +37,9 @@ export const HCI = {
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
CLUSTER: 'harvesterhci.io.management.cluster',
DASHBOARD: 'harvesterhci.io.dashboard',
+ COMPONENT_HEALTH: 'health.harvesterhci.io.componenthealth',
+ HEALTH_SUMMARY: 'health.harvesterhci.io.healthsummary',
+ HEALTH_IGNORE_RULE: 'health.harvesterhci.io.healthignorerule',
BLOCK_DEVICE: 'harvesterhci.io.blockdevice',
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
HOST: 'harvesterhci.io.host',
diff --git a/pkg/harvester/utils/component-health.js b/pkg/harvester/utils/component-health.js
new file mode 100644
index 00000000..030a9d46
--- /dev/null
+++ b/pkg/harvester/utils/component-health.js
@@ -0,0 +1,105 @@
+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
+export function ignoreRuleName(componentHealthName, checkName) {
+ return `${ componentHealthName }.${ checkName.toLowerCase() }`;
+}
+
+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;
+}