From c35c9ccf235194217eb609a0c7256de4d7da8047 Mon Sep 17 00:00:00 2001 From: Marcelo Fukumoto Date: Mon, 9 Nov 2026 15:56:15 +0000 Subject: [PATCH] feat: add resource health dashboard in a Components tab (#1225) * feat: add component health dashboard sections Add health summary and component health tables with severity filters and expandable check details. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Yu * feat: use health API group for component health resources Update dashboard resource types to use the health.harvesterhci.io API group. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Yu * feat: add component health ignore controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Yu * feat: gate component health behind the componentHealth feature flag Only show the component health dashboard sections on Harvester v1.10.0 and later. Signed-off-by: Jack Yu * fix: address component health review comments - sort the health summary table by a severity rank - use buttons instead of anchors for restore and show more actions - let the affected resources list expand so every resource can be restored - widen the ignore popup and cap its height Signed-off-by: Jack Yu * refactor: split component health into dedicated components Move the health summary, component health tabs and ignore popup out of the dashboard page into components/health, and share the constants and helpers through utils/component-health. Signed-off-by: Jack Yu * fix: sort component health by severity rank Signed-off-by: Jack Yu * fix: toggle health rows in place and tell empty filters from no checks - toggle the expanded message and resource ids by mutating the unwrapped arrays, the template does not pass refs - show "No checks match the selected filters" when the severity filters hide existing checks, keep "No issues detected" for a report without checks Signed-off-by: Jack Yu * refactor: fetch component health resources in their own components Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Yu * feat: move health into a Components tab with the checks as sub-rows The dashboard showed the summary table, then side tabs with a second table for each component. It is now one table in a new Components tab next to the Events tabs: one row per component, which opens to its checks. Rows start closed, and the component name is a button so the keyboard can open them. The tab only shows when the addon's resources exist. The table's own filter box replaces the severity dropdown. It only looks at the table's own rows and the checks are in the sub-rows, so the rows give it the text of their checks, a row only shows the checks it found, and the rows it found open. Ignoring a check moves from a popover, which cut off its Reason field and Ignore button, to an RcModal that starts over each time it opens. Signed-off-by: Marcelo Fukumoto * refactor: use RcButton for the component health buttons The Ignore modal's button already was one. This moves the rest of them, the row's Ignore, Restore, the "show more" links and the component name, off the global btn classes. Signed-off-by: Marcelo Fukumoto * refactor: manage one ignore rule per check from the row's actions A check now has one rule, reached through the table's action menu: create it when the check has none, view, edit or delete it when it has one. View and edit use the same modal as create. This replaces the Ignore button and the Restore links, which could only delete a whole rule and said the check was restored when it wasn't. The modal's section titles were the radio group's h3, as big as the modal's own title. They follow what rancher/dashboard does in its forms now. The actions follow what the user may do. The rules are only loaded with their schema, and a list that fails no longer empties the table. Signed-off-by: Marcelo Fukumoto * refactor: let the tables filter by themselves and show resources as chips The filter had logic of its own on top of the table's. The nested table now filters its rows the way the outer one does, with what is typed in the box. Rows no longer open by themselves, which is how the other tables with sub-rows behave. Expand All and Collapse All are there instead, copied from what the table has from shell 3.0.15. Affected resources are chips under the name the UI has for their type, with the usual counter badge and the API version beside it. Ignored resources are outlined instead of filled, and the text of an ignored check is muted. The links use the shell's wording. The component rows lose Last Updated. The summary only has one time, for its counts as a whole, so it said nothing about the component in the row. They say Error instead of Critical, as the checks inside them do. The Node column goes, as nothing sets its label. The Components tab shows the number of errors as a badge, as nothing else on the page tells there are any. Signed-off-by: Marcelo Fukumoto * fix: name the ignore rule after its check and make its expiry clear - Name the rule the UI makes after its check, so Kubernetes refuses a second one for the same check. The row's menu only manages that rule. - Show the rule's current expiry above the durations, and the new one under them once a duration is chosen. Edit starts with none chosen, Save stays off until something changes, and a warning says when an expired rule would stay expired. - Delete the rule through the shell's delete prompt, like every other delete. - Say which list could not be loaded, instead of showing it as empty. - Keep the full count of affected resources, say how many ignored ones are left out of the list, and count them on Show ignored. - Drop the checks' Last Updated, which is the time of their whole report. - Leave out the row menu when no check has an action. - Mark the Components tab with the shell's alert icon when there are errors, with their number in its tooltip, instead of a badge. Signed-off-by: Marcelo Fukumoto --------- Signed-off-by: Jack Yu Signed-off-by: Marcelo Fukumoto Co-authored-by: Jack Yu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 67ff2777d6dd70c0303b0d89ee44da3ad47d894c) --- .../health/ComponentChecksTable.vue | 336 +++++++ .../components/health/HealthSummaryTable.vue | 342 +++++++ .../components/health/IgnoreRuleModal.vue | 320 +++++++ .../health/__tests__/health.test.js | 897 ++++++++++++++++++ pkg/harvester/config/feature-flags.js | 1 + pkg/harvester/l10n/en-us.yaml | 52 + .../list/harvesterhci.io.dashboard.vue | 27 + ...h.harvesterhci.io.healthignorerule.test.js | 49 + ...ealth.harvesterhci.io.healthignorerule.vue | 68 ++ pkg/harvester/types.ts | 3 + pkg/harvester/utils/component-health.js | 105 ++ 11 files changed, 2200 insertions(+) create mode 100644 pkg/harvester/components/health/ComponentChecksTable.vue create mode 100644 pkg/harvester/components/health/HealthSummaryTable.vue create mode 100644 pkg/harvester/components/health/IgnoreRuleModal.vue create mode 100644 pkg/harvester/components/health/__tests__/health.test.js create mode 100644 pkg/harvester/promptRemove/__tests__/health.harvesterhci.io.healthignorerule.test.js create mode 100644 pkg/harvester/promptRemove/health.harvesterhci.io.healthignorerule.vue create mode 100644 pkg/harvester/utils/component-health.js 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; +}