mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-11-15 05:16:16 +08:00
* 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>
321 lines
9.9 KiB
Vue
321 lines
9.9 KiB
Vue
<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>
|