mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-11-15 13:26:15 +08:00
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 <jack.yu@suse.com> * 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 <jack.yu@suse.com> * feat: add component health ignore controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Yu <jack.yu@suse.com> * 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 <jack.yu@suse.com> * 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 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Jack Yu <jack.yu@suse.com> * 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. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Jack Yu <jack.yu@suse.com> * fix: sort component health by severity rank Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Jack Yu <jack.yu@suse.com> * 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 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Jack Yu <jack.yu@suse.com> * refactor: fetch component health resources in their own components Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jack Yu <jack.yu@suse.com> * 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 <marcelo.fukumoto@suse.com> * 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 <marcelo.fukumoto@suse.com> * 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 <marcelo.fukumoto@suse.com> * 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 <marcelo.fukumoto@suse.com> * 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 <marcelo.fukumoto@suse.com> --------- Signed-off-by: Jack Yu <jack.yu@suse.com> Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> Co-authored-by: Jack Yu <jack.yu@suse.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jack Yu
Copilot
Claude Sonnet 5.5
parent
73eaccc2ad
commit
67ff2777d6
@@ -0,0 +1,336 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import SortableTable from '@shell/components/SortableTable';
|
||||||
|
import { BadgeState } from '@components/BadgeState';
|
||||||
|
import { RcCounterBadge, RcTag } from '@components/Pill';
|
||||||
|
import { RcButton } from '@components/RcButton';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { _CREATE, _EDIT, _VIEW } from '@shell/config/query-params';
|
||||||
|
import { HCI } from '../../types';
|
||||||
|
import {
|
||||||
|
AFFECTED_RESOURCES_TRUNCATE_COUNT, MESSAGE_TRUNCATE_LENGTH, activeAffectedResourceNames, affectedResourceLabel,
|
||||||
|
allAffectedResourceNames, ignoreRuleName, ignoredResourceCount, resourceIgnoredBy, severityColor
|
||||||
|
} from '../../utils/component-health';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
rows: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
ignoreRules: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
showIgnored: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
// what is typed in the filter box of the table this one sits in
|
||||||
|
filterText: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
// the checks could not be loaded, so having none does not mean there are no issues
|
||||||
|
loadFailed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['open-rule']);
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{
|
||||||
|
name: 'severity',
|
||||||
|
labelKey: 'harvester.dashboard.sections.componentHealth.severity',
|
||||||
|
value: 'severityRank',
|
||||||
|
sort: 'severityRank',
|
||||||
|
search: false,
|
||||||
|
width: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'checkName',
|
||||||
|
labelKey: 'harvester.dashboard.sections.componentHealth.check',
|
||||||
|
value: 'checkName',
|
||||||
|
sort: 'checkName',
|
||||||
|
// all that a row shows, so the filter also finds a resource hidden behind "more"
|
||||||
|
search: 'filterText',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'message',
|
||||||
|
labelKey: 'harvester.dashboard.sections.componentHealth.message',
|
||||||
|
value: 'message',
|
||||||
|
search: false,
|
||||||
|
canBeVariable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'affectedCount',
|
||||||
|
labelKey: 'harvester.dashboard.sections.componentHealth.affectedCount',
|
||||||
|
value: 'affectedCount',
|
||||||
|
sort: 'affectedCount',
|
||||||
|
search: false,
|
||||||
|
width: 340,
|
||||||
|
canBeVariable: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = ref(null);
|
||||||
|
|
||||||
|
// this table has no filter box of its own, but it filters its rows the same way
|
||||||
|
watch([table, () => props.filterText], ([checksTable, text]) => {
|
||||||
|
if (checksTable) {
|
||||||
|
checksTable.searchQuery = text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const canCreateRule = computed(() => {
|
||||||
|
const schema = store.getters['harvester/schemaFor'](HCI.HEALTH_IGNORE_RULE);
|
||||||
|
|
||||||
|
return !!schema?.collectionMethods?.find((method) => method.toLowerCase() === 'post');
|
||||||
|
});
|
||||||
|
|
||||||
|
// only the rule the UI made for the check, others on it are left as they are
|
||||||
|
function actionsOf(row) {
|
||||||
|
const name = ignoreRuleName(row.componentHealthName, row.checkName);
|
||||||
|
const rule = props.ignoreRules.find(({ metadata }) => metadata?.name === name);
|
||||||
|
const action = (key, icon, invoke) => ({
|
||||||
|
action: key,
|
||||||
|
label: t(`harvester.dashboard.sections.componentHealth.ignore.${ key }`),
|
||||||
|
icon: `icon ${ icon }`,
|
||||||
|
enabled: true,
|
||||||
|
invoke,
|
||||||
|
});
|
||||||
|
const open = (mode) => () => emit('open-rule', {
|
||||||
|
row, rule, mode
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return canCreateRule.value ? [action(_CREATE, 'icon-plus', open(_CREATE))] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = [action(_VIEW, 'icon-document', open(_VIEW))];
|
||||||
|
|
||||||
|
if (rule.canUpdate) {
|
||||||
|
actions.push(action(_EDIT, 'icon-edit', open(_EDIT)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.canDelete) {
|
||||||
|
actions.push({ divider: true }, action('delete', 'icon-trash', () => rule.promptRemove()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the table's action menu reads them from the row, as it does on a resource
|
||||||
|
const rowsWithActions = computed(() => props.rows.map((row) => ({ ...row, availableActions: actionsOf(row) })));
|
||||||
|
|
||||||
|
// a menu that could only say there is nothing to do is left out
|
||||||
|
const hasActions = computed(() => rowsWithActions.value.some((row) => row.availableActions.length));
|
||||||
|
|
||||||
|
const expandedMessageIds = ref([]);
|
||||||
|
const expandedResourceIds = ref([]);
|
||||||
|
|
||||||
|
function affectedResourceNames(row) {
|
||||||
|
return props.showIgnored ? allAffectedResourceNames(row) : activeAffectedResourceNames(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// called from the template, where the refs are unwrapped, so the array is changed in place
|
||||||
|
function toggleId(list, id) {
|
||||||
|
const index = list.indexOf(id);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
list.push(id);
|
||||||
|
} else {
|
||||||
|
list.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessageTruncated(message) {
|
||||||
|
return !!message && message.length > MESSAGE_TRUNCATE_LENGTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayedMessage(row, message) {
|
||||||
|
return expandedMessageIds.value.includes(row.id) || !isMessageTruncated(message) ? message : message.slice(0, MESSAGE_TRUNCATE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleAffectedResourceNames(row) {
|
||||||
|
const names = affectedResourceNames(row);
|
||||||
|
|
||||||
|
return expandedResourceIds.value.includes(row.id) ? names : names.slice(0, AFFECTED_RESOURCES_TRUNCATE_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingAffectedResourcesCount(row) {
|
||||||
|
return Math.max(affectedResourceNames(row).length - AFFECTED_RESOURCES_TRUNCATE_COUNT, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isResourceIgnored(row, name) {
|
||||||
|
return row.ignored || !!resourceIgnoredBy(row, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the ignored resources that are left out of the list
|
||||||
|
function hiddenIgnoredCount(row) {
|
||||||
|
return props.showIgnored ? 0 : ignoredResourceCount(row);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="!rows.length"
|
||||||
|
class="text-muted"
|
||||||
|
>
|
||||||
|
{{ t(`harvester.dashboard.sections.componentHealth.${ loadFailed ? 'checksNotLoaded' : 'noIssues' }`) }}
|
||||||
|
</div>
|
||||||
|
<SortableTable
|
||||||
|
v-else
|
||||||
|
ref="table"
|
||||||
|
:rows="rowsWithActions"
|
||||||
|
:headers="headers"
|
||||||
|
key-field="id"
|
||||||
|
:search="false"
|
||||||
|
:table-actions="false"
|
||||||
|
:row-actions="hasActions"
|
||||||
|
:paging="true"
|
||||||
|
:rows-per-page="10"
|
||||||
|
default-sort-by="severity"
|
||||||
|
>
|
||||||
|
<template #cell:severity="{row}">
|
||||||
|
<BadgeState
|
||||||
|
:color="severityColor(row.severity)"
|
||||||
|
:label="row.severity"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell:checkName="{row, value}">
|
||||||
|
<span :class="{ 'text-muted': row.ignored }">
|
||||||
|
{{ value }}
|
||||||
|
<template v-if="row.ignored">
|
||||||
|
({{ t('harvester.dashboard.sections.componentHealth.ignore.ignored') }})
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #cell:message="{row, value}">
|
||||||
|
<span :class="{ 'text-muted': row.ignored }">
|
||||||
|
{{ displayedMessage(row, value) }}
|
||||||
|
<RcButton
|
||||||
|
v-if="isMessageTruncated(value)"
|
||||||
|
variant="link"
|
||||||
|
size="small"
|
||||||
|
class="compact-link"
|
||||||
|
:aria-expanded="expandedMessageIds.includes(row.id)"
|
||||||
|
@click="toggleId(expandedMessageIds, row.id)"
|
||||||
|
>
|
||||||
|
{{ expandedMessageIds.includes(row.id) ? t('generic.showLess') : t('generic.showMore') }}
|
||||||
|
</RcButton>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #cell:affectedCount="{row, value}">
|
||||||
|
<div
|
||||||
|
v-if="affectedResourceNames(row).length"
|
||||||
|
class="affected-resources"
|
||||||
|
>
|
||||||
|
<div class="affected-title">
|
||||||
|
<span class="text-label">{{ affectedResourceLabel(row, store) }}</span>
|
||||||
|
<RcCounterBadge
|
||||||
|
:count="value || allAffectedResourceNames(row).length"
|
||||||
|
type="inactive"
|
||||||
|
/>
|
||||||
|
<span class="api-version text-muted">{{ row.affectedResources.apiVersion }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="affected-names">
|
||||||
|
<RcTag
|
||||||
|
v-for="name in visibleAffectedResourceNames(row)"
|
||||||
|
:key="name"
|
||||||
|
class="affected-name"
|
||||||
|
:class="{ ignored: isResourceIgnored(row, name) }"
|
||||||
|
type="inactive"
|
||||||
|
:disabled="isResourceIgnored(row, name)"
|
||||||
|
>
|
||||||
|
<span class="name">{{ name }}</span>
|
||||||
|
<!-- when all of the check is ignored its name says so -->
|
||||||
|
<span v-if="!row.ignored && isResourceIgnored(row, name)">
|
||||||
|
({{ t('harvester.dashboard.sections.componentHealth.ignore.ignored') }})
|
||||||
|
</span>
|
||||||
|
</RcTag>
|
||||||
|
</div>
|
||||||
|
<RcButton
|
||||||
|
v-if="remainingAffectedResourcesCount(row)"
|
||||||
|
variant="link"
|
||||||
|
size="small"
|
||||||
|
class="compact-link"
|
||||||
|
:aria-expanded="expandedResourceIds.includes(row.id)"
|
||||||
|
@click="toggleId(expandedResourceIds, row.id)"
|
||||||
|
>
|
||||||
|
{{ expandedResourceIds.includes(row.id) ? t('generic.showLess') : t('generic.plusMore', { n: remainingAffectedResourcesCount(row) }) }}
|
||||||
|
</RcButton>
|
||||||
|
<span
|
||||||
|
v-if="hiddenIgnoredCount(row)"
|
||||||
|
class="hidden-ignored text-muted"
|
||||||
|
>
|
||||||
|
{{ t('harvester.dashboard.sections.componentHealth.ignore.plusIgnored', { n: hiddenIgnoredCount(row) }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else>
|
||||||
|
{{ t('harvester.dashboard.sections.componentHealth.noAffectedResources') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</SortableTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
// these sit next to text, so they line up with it
|
||||||
|
.compact-link {
|
||||||
|
--rc-button-padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affected-resources {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affected-title {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-version {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the same size as the "+ N more" link above it
|
||||||
|
.hidden-ignored {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affected-names {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affected-name {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
// a long name wraps, so none is ever cut off
|
||||||
|
.name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the ones that count are filled, the ignored ones are only outlined
|
||||||
|
&.rc-tag.ignored {
|
||||||
|
background: transparent;
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: var(--rc-disabled-text-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { HCI } from '../../types';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import SortableTable from '@shell/components/SortableTable';
|
||||||
|
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||||
|
import Banner from '@components/Banner/Banner.vue';
|
||||||
|
import { BadgeState } from '@components/BadgeState';
|
||||||
|
import { Checkbox } from '@components/Form/Checkbox';
|
||||||
|
import { RcButton } from '@components/RcButton';
|
||||||
|
import { useModal } from '@components/RcModal';
|
||||||
|
import ComponentChecksTable from './ComponentChecksTable.vue';
|
||||||
|
import IgnoreRuleModal from './IgnoreRuleModal.vue';
|
||||||
|
import {
|
||||||
|
COMPONENT_HEALTH_SEVERITIES, HEALTH_SUMMARY_SEVERITY_RANK, activeAffectedResourceNames, affectedResourceKind,
|
||||||
|
affectedResourceLabel, allAffectedResourceNames, componentOf, healthSummarySeverity, ignoredCounts, severityColor
|
||||||
|
} from '../../utils/component-health';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
const table = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const lists = {
|
||||||
|
healthSummaries: ref([]),
|
||||||
|
componentHealths: ref([]),
|
||||||
|
ignoreRules: ref([]),
|
||||||
|
};
|
||||||
|
const { healthSummaries, componentHealths, ignoreRules } = lists;
|
||||||
|
// the lists that could not be loaded, with why
|
||||||
|
const loadErrors = ref({});
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{
|
||||||
|
name: 'severity',
|
||||||
|
labelKey: 'harvester.dashboard.sections.healthSummary.severity',
|
||||||
|
value: 'severityRank',
|
||||||
|
sort: 'severityRank',
|
||||||
|
search: 'severity',
|
||||||
|
width: 110,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'component',
|
||||||
|
labelKey: 'harvester.dashboard.sections.healthSummary.component',
|
||||||
|
value: 'component',
|
||||||
|
sort: 'component',
|
||||||
|
// the table's filter only looks at its own rows, the checks are in the sub-rows
|
||||||
|
search: ['component', 'checksFilterText'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
align: 'right',
|
||||||
|
name: 'errorCount',
|
||||||
|
labelKey: 'harvester.dashboard.sections.healthSummary.errorCount',
|
||||||
|
value: 'errorCount',
|
||||||
|
sort: 'errorCount',
|
||||||
|
search: false,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
align: 'right',
|
||||||
|
name: 'warningCount',
|
||||||
|
labelKey: 'harvester.dashboard.sections.healthSummary.warningCount',
|
||||||
|
value: 'warningCount',
|
||||||
|
sort: 'warningCount',
|
||||||
|
search: false,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const {
|
||||||
|
open: openRuleModal, isOpen: ruleModalOpen, payload: ruleToOpen, modal: ruleModal
|
||||||
|
} = useModal();
|
||||||
|
|
||||||
|
const showIgnored = ref(false);
|
||||||
|
const filterText = computed(() => table.value?.searchQuery || '');
|
||||||
|
|
||||||
|
const checks = computed(() => {
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
(componentHealths.value || []).forEach((componentHealth) => {
|
||||||
|
const component = componentOf(componentHealth);
|
||||||
|
const statusChecks = componentHealth?.status?.checks || {};
|
||||||
|
|
||||||
|
Object.entries(statusChecks).forEach(([checkName, check]) => {
|
||||||
|
result.push({
|
||||||
|
id: `${ componentHealth.id }/${ checkName }`,
|
||||||
|
component,
|
||||||
|
componentHealthName: componentHealth.metadata?.name,
|
||||||
|
checkName,
|
||||||
|
ignored: !!check.ignored,
|
||||||
|
severity: check.severity,
|
||||||
|
// the table sorts on a row property, the severities are not in alphabetical order
|
||||||
|
severityRank: COMPONENT_HEALTH_SEVERITIES.indexOf(check.severity),
|
||||||
|
message: check.message,
|
||||||
|
affectedCount: check.affectedCount,
|
||||||
|
affectedResources: check.affectedResources,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const shownChecks = computed(() => checks.value.filter((check) => showIgnored.value || !check.ignored).map((check) => ({
|
||||||
|
...check,
|
||||||
|
filterText: [
|
||||||
|
check.component,
|
||||||
|
check.severity,
|
||||||
|
check.checkName,
|
||||||
|
check.message,
|
||||||
|
check.affectedResources?.apiVersion,
|
||||||
|
affectedResourceKind(check),
|
||||||
|
affectedResourceLabel(check, store),
|
||||||
|
...(showIgnored.value ? allAffectedResourceNames(check) : activeAffectedResourceNames(check)),
|
||||||
|
].join(' ').toLowerCase(),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const ignored = computed(() => ignoredCounts(checks.value));
|
||||||
|
const hasIgnored = computed(() => ignored.value.checks + ignored.value.resources > 0);
|
||||||
|
|
||||||
|
// says what turning it on brings back, the same whether it is on or off
|
||||||
|
const showIgnoredLabel = computed(() => {
|
||||||
|
const counts = [
|
||||||
|
ignored.value.checks && t('harvester.dashboard.sections.componentHealth.ignoredChecks', { count: ignored.value.checks }),
|
||||||
|
ignored.value.resources && t('harvester.dashboard.sections.componentHealth.ignoredResources', { count: ignored.value.resources }),
|
||||||
|
].filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
return t('harvester.dashboard.sections.componentHealth.showIgnored', { counts });
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
(healthSummaries.value || []).forEach((healthSummary) => {
|
||||||
|
const components = healthSummary?.status?.components || {};
|
||||||
|
|
||||||
|
Object.entries(components).forEach(([component, counts]) => {
|
||||||
|
const componentChecks = shownChecks.value.filter((check) => check.component === component);
|
||||||
|
const row = {
|
||||||
|
id: `${ healthSummary.id }/${ component }`,
|
||||||
|
component,
|
||||||
|
errorCount: counts?.errorCount || 0,
|
||||||
|
warningCount: counts?.warningCount || 0,
|
||||||
|
checksFilterText: componentChecks.map((check) => check.filterText).join(' '),
|
||||||
|
checks: componentChecks,
|
||||||
|
};
|
||||||
|
|
||||||
|
row.severity = healthSummarySeverity(row);
|
||||||
|
// the table sorts on a row property, so the severity has to be on the row
|
||||||
|
row.severityRank = HEALTH_SUMMARY_SEVERITY_RANK[row.severity];
|
||||||
|
|
||||||
|
result.push(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async() => {
|
||||||
|
const types = {
|
||||||
|
healthSummaries: HCI.HEALTH_SUMMARY,
|
||||||
|
componentHealths: HCI.COMPONENT_HEALTH,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (store.getters['harvester/schemaFor'](HCI.HEALTH_IGNORE_RULE)) {
|
||||||
|
types.ignoreRules = HCI.HEALTH_IGNORE_RULE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a list that fails does not stop the others from showing
|
||||||
|
await Promise.all(Object.entries(types).map(async([key, type]) => {
|
||||||
|
try {
|
||||||
|
lists[key].value = await store.dispatch('harvester/findAll', { type });
|
||||||
|
} catch (err) {
|
||||||
|
loadErrors.value[key] = exceptionToErrorsArray(err).join(', ');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// the table has these from shell 3.0.15, in the props of its #header-right slot, so they can go with that bump
|
||||||
|
function expandAll() {
|
||||||
|
table.value.expanded = Object.fromEntries(rows.value.map((row) => [row.id, true]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAll() {
|
||||||
|
table.value.expanded = {};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Banner
|
||||||
|
v-for="(error, key) in loadErrors"
|
||||||
|
:key="key"
|
||||||
|
color="error"
|
||||||
|
:label="t(`harvester.dashboard.sections.componentHealth.loadFailed.${ key }`, { error })"
|
||||||
|
/>
|
||||||
|
<SortableTable
|
||||||
|
ref="table"
|
||||||
|
:rows="rows"
|
||||||
|
:headers="headers"
|
||||||
|
:loading="loading"
|
||||||
|
key-field="id"
|
||||||
|
:table-actions="false"
|
||||||
|
:row-actions="false"
|
||||||
|
:paging="true"
|
||||||
|
:rows-per-page="10"
|
||||||
|
:sub-rows="true"
|
||||||
|
:sub-expandable="true"
|
||||||
|
:sub-expand-column="true"
|
||||||
|
default-sort-by="severity"
|
||||||
|
>
|
||||||
|
<template #header-left>
|
||||||
|
<div
|
||||||
|
v-if="hasIgnored"
|
||||||
|
class="show-ignored"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
v-model:value="showIgnored"
|
||||||
|
type="checkbox"
|
||||||
|
:label="showIgnoredLabel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #header-right>
|
||||||
|
<div
|
||||||
|
v-if="rows.length"
|
||||||
|
class="expand-actions"
|
||||||
|
>
|
||||||
|
<RcButton
|
||||||
|
variant="link"
|
||||||
|
size="small"
|
||||||
|
@click="expandAll"
|
||||||
|
>
|
||||||
|
{{ t('harvester.dashboard.sections.healthSummary.expandAll') }}
|
||||||
|
</RcButton>
|
||||||
|
<RcButton
|
||||||
|
variant="link"
|
||||||
|
size="small"
|
||||||
|
@click="collapseAll"
|
||||||
|
>
|
||||||
|
{{ t('harvester.dashboard.sections.healthSummary.collapseAll') }}
|
||||||
|
</RcButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell:severity="{row}">
|
||||||
|
<BadgeState
|
||||||
|
:color="severityColor(row.severity)"
|
||||||
|
:label="row.severity"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<!-- the table's chevron can't take the keyboard focus, this opens the row from the keyboard -->
|
||||||
|
<template #cell:component="{row}">
|
||||||
|
<RcButton
|
||||||
|
variant="link"
|
||||||
|
class="component-name"
|
||||||
|
:aria-expanded="!!table?.isExpanded(row)"
|
||||||
|
@click="table.toggleExpand(row)"
|
||||||
|
>
|
||||||
|
{{ row.component }}
|
||||||
|
</RcButton>
|
||||||
|
</template>
|
||||||
|
<template #sub-row="{row, fullColspan}">
|
||||||
|
<tr class="sub-row">
|
||||||
|
<td :colspan="fullColspan">
|
||||||
|
<ComponentChecksTable
|
||||||
|
class="sub-table"
|
||||||
|
:rows="row.checks"
|
||||||
|
:ignore-rules="ignoreRules"
|
||||||
|
:show-ignored="showIgnored"
|
||||||
|
:filter-text="filterText"
|
||||||
|
:load-failed="!!loadErrors.componentHealths"
|
||||||
|
@open-rule="openRuleModal"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</SortableTable>
|
||||||
|
<!-- mounted each time it opens, so the form always starts over -->
|
||||||
|
<IgnoreRuleModal
|
||||||
|
v-if="ruleModalOpen"
|
||||||
|
v-bind="ruleToOpen"
|
||||||
|
:modal="ruleModal"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
// the filter box in the same row is taller, this lines up with it
|
||||||
|
.show-ignored {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a button is taller than the table's rows
|
||||||
|
.component-name {
|
||||||
|
--rc-button-padding: 0;
|
||||||
|
|
||||||
|
&.rc-button.btn-medium:not(.btn-sm) {
|
||||||
|
min-height: 0;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the shell has this look for a table in a sub-row from 3.0.15, this can go with that bump
|
||||||
|
.sub-row {
|
||||||
|
background-color: var(--body-bg) !important;
|
||||||
|
|
||||||
|
> td:first-child {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-table {
|
||||||
|
padding: 16px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sortable-table) {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(thead tr:hover) {
|
||||||
|
background-color: var(--body-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(thead th) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(tbody tr) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import isEqual from 'lodash/isEqual';
|
||||||
|
import Banner from '@components/Banner/Banner.vue';
|
||||||
|
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||||
|
import { RadioGroup } from '@components/Form/Radio';
|
||||||
|
import { RcButton } from '@components/RcButton';
|
||||||
|
import { RcHeading } from '@components/RcHeading';
|
||||||
|
import { RcModal } from '@components/RcModal';
|
||||||
|
import LabelValue from '@shell/components/LabelValue';
|
||||||
|
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||||
|
import { useI18n } from '@shell/composables/useI18n';
|
||||||
|
import { _CREATE, _VIEW } from '@shell/config/query-params';
|
||||||
|
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||||
|
import { dateTimeFormat } from '@shell/utils/time';
|
||||||
|
import { HCI } from '../../types';
|
||||||
|
import { IGNORE_DURATION_DAYS, IGNORE_RULE_SOURCE_LABEL, allAffectedResourceNames, ignoreRuleName } from '../../utils/component-health';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
row: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
// empty when the rule is created
|
||||||
|
rule: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: _CREATE,
|
||||||
|
},
|
||||||
|
// `show` and the `close` handler, from whoever owns this modal's visibility
|
||||||
|
modal: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { t } = useI18n(store);
|
||||||
|
|
||||||
|
const isView = computed(() => props.mode === _VIEW);
|
||||||
|
|
||||||
|
const ruleResources = props.rule?.spec?.resources || [];
|
||||||
|
const expiresAt = props.rule?.spec?.expiresAt;
|
||||||
|
const hasExpired = !!expiresAt && !dayjs(expiresAt).isAfter(dayjs());
|
||||||
|
|
||||||
|
// a rule can still list resources that are no longer affected
|
||||||
|
const resourceOptions = computed(() => [...new Set([...allAffectedResourceNames(props.row), ...ruleResources])]);
|
||||||
|
|
||||||
|
// a rule that lists no resource ignores the entire check
|
||||||
|
const chosenScope = ref(props.rule && !ruleResources.length ? 'check' : null);
|
||||||
|
const scope = computed({
|
||||||
|
get: () => chosenScope.value || (resourceOptions.value.length ? 'resources' : 'check'),
|
||||||
|
set: (value) => {
|
||||||
|
chosenScope.value = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// null selects every resource, the ones that are not selected are kept visible
|
||||||
|
const selected = ref(ruleResources.length ? ruleResources : null);
|
||||||
|
// a rule keeps the expiry it has until another duration is chosen
|
||||||
|
const duration = ref(props.rule ? null : 'days7');
|
||||||
|
const reason = ref(props.rule?.spec?.reason || '');
|
||||||
|
const errors = ref([]);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const selectedResources = computed(() => {
|
||||||
|
return selected.value === null ? resourceOptions.value : resourceOptions.value.filter((name) => selected.value.includes(name));
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopeOptions = computed(() => [
|
||||||
|
{ label: t('harvester.dashboard.sections.componentHealth.ignore.scopeCheck'), value: 'check' },
|
||||||
|
{ label: t('harvester.dashboard.sections.componentHealth.ignore.scopeResources'), value: 'resources' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const durationOptions = computed(() => Object.keys(IGNORE_DURATION_DAYS).map((key) => ({
|
||||||
|
label: t(`harvester.dashboard.sections.componentHealth.ignore.${ key }`),
|
||||||
|
value: key,
|
||||||
|
})));
|
||||||
|
|
||||||
|
// worked out once for each choice, so the date shown is the date saved
|
||||||
|
const newExpiresAt = computed(() => {
|
||||||
|
const days = IGNORE_DURATION_DAYS[duration.value];
|
||||||
|
|
||||||
|
return days ? dayjs().add(days, 'day').toISOString() : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const spec = computed(() => {
|
||||||
|
const out = {
|
||||||
|
componentHealth: props.row.componentHealthName,
|
||||||
|
checkKey: props.row.checkName,
|
||||||
|
};
|
||||||
|
|
||||||
|
// the rule only lists the selected resources, so it does not swallow the ones coming later
|
||||||
|
if (scope.value === 'resources') {
|
||||||
|
out.resources = selectedResources.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiry = duration.value ? newExpiresAt.value : expiresAt;
|
||||||
|
|
||||||
|
if (expiry) {
|
||||||
|
out.expiresAt = expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason.value) {
|
||||||
|
out.reason = reason.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
// what the form changes, in an order that does not depend on the options
|
||||||
|
const editable = (ruleSpec) => ({
|
||||||
|
resources: [...(ruleSpec?.resources || [])].sort(),
|
||||||
|
expiresAt: ruleSpec?.expiresAt || '',
|
||||||
|
reason: ruleSpec?.reason || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasChanges = computed(() => !props.rule || !isEqual(editable(spec.value), editable(props.rule.spec)));
|
||||||
|
|
||||||
|
// a rule that lists no resource would ignore the entire check
|
||||||
|
const canSave = computed(() => (scope.value === 'check' || selectedResources.value.length > 0) && hasChanges.value);
|
||||||
|
|
||||||
|
// saving something else on an expired rule leaves it expired
|
||||||
|
const staysExpired = computed(() => hasExpired && !duration.value && hasChanges.value);
|
||||||
|
|
||||||
|
const currentExpiry = computed(() => {
|
||||||
|
if (!expiresAt) {
|
||||||
|
return t('harvester.dashboard.sections.componentHealth.ignore.neverExpires');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t(`harvester.dashboard.sections.componentHealth.ignore.${ hasExpired ? 'expiredOn' : 'expiresOn' }`, { date: dateTimeFormat(expiresAt, store) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const newExpiry = computed(() => {
|
||||||
|
if (!newExpiresAt.value) {
|
||||||
|
return t('harvester.dashboard.sections.componentHealth.ignore.willNeverExpire');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('harvester.dashboard.sections.componentHealth.ignore.willExpireOn', { date: dateTimeFormat(newExpiresAt.value, store) });
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
// stays open while the rule is saved, so a failure can still be shown
|
||||||
|
if (!saving.value) {
|
||||||
|
props.modal.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
errors.value = [];
|
||||||
|
saving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rule = props.rule ? await store.dispatch('harvester/clone', { resource: props.rule }) : await store.dispatch('harvester/create', {
|
||||||
|
type: HCI.HEALTH_IGNORE_RULE,
|
||||||
|
metadata: {
|
||||||
|
name: ignoreRuleName(props.row.componentHealthName, props.row.checkName),
|
||||||
|
labels: { [IGNORE_RULE_SOURCE_LABEL]: 'user' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
rule.spec = spec.value;
|
||||||
|
await rule.save();
|
||||||
|
|
||||||
|
store.dispatch('growl/success', {
|
||||||
|
title: t('generic.notification.title.succeed'),
|
||||||
|
message: t(`harvester.dashboard.sections.componentHealth.ignore.${ props.rule ? 'updated' : 'created' }`, { name: props.row.checkName }),
|
||||||
|
}, { root: true });
|
||||||
|
|
||||||
|
saving.value = false;
|
||||||
|
close();
|
||||||
|
} catch (err) {
|
||||||
|
errors.value = exceptionToErrorsArray(err?.data || err);
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Not v-bind="modal": closing goes through close(), which refuses while the rule is saved -->
|
||||||
|
<RcModal
|
||||||
|
:show="modal.show"
|
||||||
|
:title="t(`harvester.dashboard.sections.componentHealth.ignore.${ mode }`)"
|
||||||
|
data-testid="component-health-ignore-modal"
|
||||||
|
@close="close"
|
||||||
|
@primary-action="save"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col span-6">
|
||||||
|
<LabelValue
|
||||||
|
:name="t('harvester.dashboard.sections.componentHealth.component')"
|
||||||
|
:value="row.component"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col span-6">
|
||||||
|
<LabelValue
|
||||||
|
:name="t('harvester.dashboard.sections.componentHealth.check')"
|
||||||
|
:value="row.checkName"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="resourceOptions.length">
|
||||||
|
<RcHeading :size="4">
|
||||||
|
{{ t('harvester.dashboard.sections.componentHealth.ignore.scope') }}
|
||||||
|
</RcHeading>
|
||||||
|
<RadioGroup
|
||||||
|
v-model:value="scope"
|
||||||
|
name="ignore-scope"
|
||||||
|
:aria-label="t('harvester.dashboard.sections.componentHealth.ignore.scope')"
|
||||||
|
:mode="mode"
|
||||||
|
:options="scopeOptions"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="scope === 'resources'"
|
||||||
|
class="ignore-resources"
|
||||||
|
>
|
||||||
|
<LabeledSelect
|
||||||
|
:value="selectedResources"
|
||||||
|
:label="t('harvester.dashboard.sections.componentHealth.ignore.resources')"
|
||||||
|
:mode="mode"
|
||||||
|
:options="resourceOptions"
|
||||||
|
:multiple="true"
|
||||||
|
:close-on-select="false"
|
||||||
|
:clearable="false"
|
||||||
|
:required="!isView"
|
||||||
|
@update:value="selected = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<RcHeading :size="4">
|
||||||
|
{{ t('harvester.dashboard.sections.componentHealth.ignore.duration') }}
|
||||||
|
</RcHeading>
|
||||||
|
<p
|
||||||
|
v-if="rule"
|
||||||
|
class="current-expiry mb-10"
|
||||||
|
data-testid="component-health-ignore-current-expiry"
|
||||||
|
>
|
||||||
|
{{ currentExpiry }}
|
||||||
|
</p>
|
||||||
|
<template v-if="!isView">
|
||||||
|
<RadioGroup
|
||||||
|
v-model:value="duration"
|
||||||
|
name="ignore-duration"
|
||||||
|
:aria-label="t('harvester.dashboard.sections.componentHealth.ignore.duration')"
|
||||||
|
:mode="mode"
|
||||||
|
:options="durationOptions"
|
||||||
|
:row="true"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="duration"
|
||||||
|
class="new-expiry text-muted"
|
||||||
|
data-testid="component-health-ignore-new-expiry"
|
||||||
|
>
|
||||||
|
{{ newExpiry }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<LabeledInput
|
||||||
|
v-model:value="reason"
|
||||||
|
:label="t('harvester.dashboard.sections.componentHealth.ignore.reason')"
|
||||||
|
:placeholder="isView ? '' : t('harvester.dashboard.sections.componentHealth.ignore.reasonPlaceholder')"
|
||||||
|
:mode="mode"
|
||||||
|
/>
|
||||||
|
<Banner
|
||||||
|
v-for="(err, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
class="m-0"
|
||||||
|
color="error"
|
||||||
|
:label="err"
|
||||||
|
/>
|
||||||
|
<Banner
|
||||||
|
v-if="staysExpired"
|
||||||
|
class="m-0"
|
||||||
|
color="warning"
|
||||||
|
data-testid="component-health-ignore-stays-expired"
|
||||||
|
:label="t('harvester.dashboard.sections.componentHealth.ignore.staysExpired', { date: dateTimeFormat(expiresAt, store) })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-if="isView"
|
||||||
|
#actions="{ close: closeModal }"
|
||||||
|
>
|
||||||
|
<RcButton
|
||||||
|
variant="primary"
|
||||||
|
size="large"
|
||||||
|
data-testid="component-health-ignore-close"
|
||||||
|
@click="closeModal"
|
||||||
|
>
|
||||||
|
{{ t('generic.close') }}
|
||||||
|
</RcButton>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else
|
||||||
|
#primary-action="{ primaryAction }"
|
||||||
|
>
|
||||||
|
<RcButton
|
||||||
|
variant="primary"
|
||||||
|
size="large"
|
||||||
|
:disabled="!canSave || saving"
|
||||||
|
:aria-busy="saving"
|
||||||
|
data-testid="component-health-ignore-confirm"
|
||||||
|
@click="primaryAction"
|
||||||
|
>
|
||||||
|
{{ t(rule ? 'generic.save' : 'generic.create') }}
|
||||||
|
</RcButton>
|
||||||
|
</template>
|
||||||
|
</RcModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
// lines the field up with the radio's label
|
||||||
|
.ignore-resources {
|
||||||
|
margin: 10px 0 0 19px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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: `<div>
|
||||||
|
<slot name="header-left" />
|
||||||
|
<slot name="header-right" />
|
||||||
|
<div class="summary">{{ rows.map(r => r.id + ":" + r.severityRank).join(",") }}</div>
|
||||||
|
<div v-for="row in rows" :key="row.id" class="name"><slot name="cell:component" :row="row" /></div>
|
||||||
|
<div v-for="row in rows" :key="row.id" :class="'sub-' + row.component"><slot name="sub-row" :row="row" :fullColspan="1" /></div>
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const checksTable = {
|
||||||
|
props: ['rows', 'headers', 'rowActions'],
|
||||||
|
data() {
|
||||||
|
return { searchQuery: '' };
|
||||||
|
},
|
||||||
|
template: '<div class="checks">{{ rows.map(r => r.id + ":" + r.severityRank).join(",") }}</div>',
|
||||||
|
};
|
||||||
|
|
||||||
|
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: '<div><slot name="cell:message" :row="rows[0]" :value="rows[0].message" /></div>' };
|
||||||
|
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: '<div><slot name="cell:affectedCount" :row="rows[0]" :value="rows[0].affectedCount" /></div>' };
|
||||||
|
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: '<div><slot name="cell:affectedCount" :row="rows[0]" :value="rows[0].affectedCount" /></div>' };
|
||||||
|
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: '<div><slot name="cell:severity" :row="rows[0]" /><slot name="cell:affectedCount" :row="rows[0]" :value="2" /></div>' };
|
||||||
|
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: '<div><slot name="cell:affectedCount" :row="rows[0]" :value="rows[0].affectedCount" /></div>' };
|
||||||
|
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: '<div><slot name="cell:checkName" :row="rows[0]" :value="rows[0].checkName" /><slot name="cell:affectedCount" :row="rows[0]" :value="2" /></div>',
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,6 +85,7 @@ const FEATURE_FLAGS = {
|
|||||||
'v1.10.0': [
|
'v1.10.0': [
|
||||||
'maintenanceModeConditions',
|
'maintenanceModeConditions',
|
||||||
'maintenanceModeDrainTimeoutSetting',
|
'maintenanceModeDrainTimeoutSetting',
|
||||||
|
'componentHealth',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -513,6 +513,58 @@ harvester:
|
|||||||
label: Events
|
label: Events
|
||||||
vmMetrics:
|
vmMetrics:
|
||||||
label: Virtual Machine Metrics
|
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
|
version: Version
|
||||||
host:
|
host:
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ import { allDashboardsExist } from '@shell/utils/grafana';
|
|||||||
import { isEmpty } from '@shell/utils/object';
|
import { isEmpty } from '@shell/utils/object';
|
||||||
import { HCI } from '../types';
|
import { HCI } from '../types';
|
||||||
import HarvesterUpgrade from '../components/HarvesterUpgrade';
|
import HarvesterUpgrade from '../components/HarvesterUpgrade';
|
||||||
|
import HealthSummaryTable from '../components/health/HealthSummaryTable';
|
||||||
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../config/harvester';
|
import { PRODUCT_NAME as HARVESTER_PRODUCT } from '../config/harvester';
|
||||||
import { UNIT_SUFFIX } from '../utils/unit';
|
import { UNIT_SUFFIX } from '../utils/unit';
|
||||||
|
import { totalErrorCount } from '../utils/component-health';
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(minMax);
|
dayjs.extend(minMax);
|
||||||
@@ -125,6 +127,7 @@ export default {
|
|||||||
DashboardMetrics,
|
DashboardMetrics,
|
||||||
Banner,
|
Banner,
|
||||||
MessageLink,
|
MessageLink,
|
||||||
|
HealthSummaryTable,
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetch() {
|
async fetch() {
|
||||||
@@ -237,6 +240,20 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapGetters(['currentCluster']),
|
...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() {
|
accessibleResources() {
|
||||||
const inStore = this.$store.getters['currentProduct'].inStore;
|
const inStore = this.$store.getters['currentProduct'].inStore;
|
||||||
|
|
||||||
@@ -821,6 +838,16 @@ export default {
|
|||||||
</template>
|
</template>
|
||||||
</SortableTable>
|
</SortableTable>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
v-if="componentHealthEnabled"
|
||||||
|
name="components"
|
||||||
|
:label="t('harvester.dashboard.sections.componentHealth.label')"
|
||||||
|
:weight="95"
|
||||||
|
:display-alert-icon="componentErrorCount > 0"
|
||||||
|
:error-icon-tooltip="t('harvester.dashboard.sections.componentHealth.errors', { count: componentErrorCount })"
|
||||||
|
>
|
||||||
|
<HealthSummaryTable />
|
||||||
|
</Tab>
|
||||||
</Tabbed>
|
</Tabbed>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import Banner from '@components/Banner/Banner.vue';
|
||||||
|
import { exceptionToErrorsArray } from '@shell/utils/error';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PromptRemoveHealthIgnoreRuleDialog',
|
||||||
|
|
||||||
|
components: { Banner },
|
||||||
|
|
||||||
|
// the prompt binds all of its own data to this body, none of it belongs on the element
|
||||||
|
inheritAttrs: false,
|
||||||
|
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Array,
|
||||||
|
default: () => {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
close: {
|
||||||
|
type: Function,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return { errors: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapGetters({ t: 'i18n/t' }),
|
||||||
|
|
||||||
|
// a rule's name means little, the check it is for is what the user knows
|
||||||
|
checkNames() {
|
||||||
|
return this.value.map((rule) => rule.spec?.checkKey).join(', ');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
// the prompt does not show the errors of a body that removes by itself
|
||||||
|
async remove(buttonDone) {
|
||||||
|
try {
|
||||||
|
await Promise.all(this.value.map((rule) => rule.remove()));
|
||||||
|
this.close(buttonDone);
|
||||||
|
} catch (err) {
|
||||||
|
this.errors = exceptionToErrorsArray(err);
|
||||||
|
buttonDone(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-10">
|
||||||
|
<div class="mb-5">
|
||||||
|
{{ t('harvester.dashboard.sections.componentHealth.ignore.attemptingToDelete') }} <b>{{ checkNames }}</b>. {{ t('harvester.dashboard.sections.componentHealth.ignore.reportedAgain') }}
|
||||||
|
</div>
|
||||||
|
<Banner
|
||||||
|
v-for="(error, i) in errors"
|
||||||
|
:key="i"
|
||||||
|
color="error"
|
||||||
|
:label="error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -37,6 +37,9 @@ export const HCI = {
|
|||||||
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
|
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',
|
||||||
CLUSTER: 'harvesterhci.io.management.cluster',
|
CLUSTER: 'harvesterhci.io.management.cluster',
|
||||||
DASHBOARD: 'harvesterhci.io.dashboard',
|
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',
|
BLOCK_DEVICE: 'harvesterhci.io.blockdevice',
|
||||||
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
|
CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate',
|
||||||
HOST: 'harvesterhci.io.host',
|
HOST: 'harvesterhci.io.host',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user