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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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; +}