Incorporate the batch-start published drawer frontier

Preserve existing reviewed integrations and published attention-reason presentation without re-parenting source commits.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-05 07:37:50 +01:00
8 changed files with 199 additions and 10 deletions
@@ -2046,6 +2046,14 @@ is a frontend-primitives boundary. `ResourceDetailDrawerOverviewTab.tsx`,
`ResourceActionHistory.tsx`, and `DiscoveryTab.tsx` must compose
`InfoCardFrame` for that shell instead of restoring local card-frame
classes.
The Overview tab's Needs attention list merges open alerts with the health
presenter's reasons (`getResourceHealthIssuePresentation`), skipping only the
reasons an open alert already states, so one matching alert never hides the
rest. The presenter keeps every incident summary, not only the rollup, adds a
TrueNAS dataset's state (`state:readonly`, `state:locked`, `state:unmounted`)
because a read-only dataset raises no incident, and uses the rollup's category
label (Resource Health Issue, Capacity Pressure) only when no reason is more
specific.
Machine and host overview cards that render compact system, hardware, disk,
and temperature facts must also compose the frontend-primitives
`InfoCardKeyValueRow`. Mobile rows retain their condensed endpoint layout;
@@ -0,0 +1,39 @@
{
"version": 1,
"base_sha": "fdcffc64114bd9d7ff7a92324391a09702c3c26a",
"verified_at": "2026-10-05T05:54:42Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
"frontend-modern/src/components/Infrastructure/resourceHealthPresentation.ts",
"frontend-modern/src/utils/truenasDatasetState.ts"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "b22652a0572e3be7e67505a5fc1587160c665f96389c845c110ada73a7281bcd",
"frontend-modern/src/components/Infrastructure/resourceHealthPresentation.ts": "eebcae07481b8dff2be38e7bdc91c1cb20247c866ea2ebea3208b0bbadfddcd6",
"frontend-modern/src/utils/truenasDatasetState.ts": "335fb4c7834a2b692f6e2a892b610399cf7831c6f4a5428ce391243be4746332"
},
"routes": [
"/truenas/storage",
"/standalone/machines"
],
"viewports": [
{
"width": 1440,
"height": 1000
},
{
"width": 390,
"height": 844
}
],
"states": [
"Phone drawer for archive/cold (read-only dataset): the header badge and the Needs attention list now say Dataset is read-only, where before the drawer only said Observed state Degraded",
"Phone and desktop drawer for the archive pool: Needs attention lists the SMART failure, the pool DEGRADED alert with its mirror detail, and ZFS pool archive is DEGRADED. Before, the second incident was missing and a generic Resource Health Issue line stood in its place",
"Machines at 1700: host rows with no health issue are unchanged"
],
"interactions": [
"Tapped archive and archive/cold on a 390 phone and read each drawer",
"Clicked the archive pool at 1440 and read the Needs attention list"
]
}
@@ -370,16 +370,27 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
severity: alert.level,
}));
const healthIssue = drawer.healthIssue();
if (healthIssue && !items.some((item) => item.message === healthIssue.primary)) {
if (healthIssue) {
// Skip only the reasons an open alert already states. One matching alert
// must not hide the other reasons (a pool's own degraded state beside a
// copied disk alert).
const messageKey = (message: string) =>
message
.trim()
.toLowerCase()
.replace(/[.\s]+$/, '');
const stated = new Set(items.map((item) => messageKey(item.message)));
const severity = /critical|failed|faulted|error/i.test(resource().status)
? 'critical'
: 'warning';
items.unshift(
...[healthIssue.primary, ...healthIssue.details].map((message, index) => ({
id: `resource-health-issue-${index}`,
message,
severity,
})),
...[healthIssue.primary, ...healthIssue.details]
.filter((message) => !stated.has(messageKey(message)))
.map((message, index) => ({
id: `resource-health-issue-${index}`,
message,
severity,
})),
);
}
return items;
@@ -445,4 +445,43 @@ describe('ResourceDetailDrawer service cards', () => {
expect(getByText('Trusted')).toBeInTheDocument();
expect(getByText('Example CA')).toBeInTheDocument();
});
it('keeps the other attention reasons when an open alert already states one', () => {
// A TrueNAS pool carries its own degraded alert and the copied disk alert.
// One matching open alert must not hide the pool's own reason.
const resource = baseResource({
type: 'storage',
status: 'degraded',
incidentSummary: 'Device /dev/sdc has SMART test failures.',
incidents: [
{
code: 'truenas_volume_status',
severity: 'warning',
summary: 'Pool archive is DEGRADED: one member of mirror-0 is faulted.',
},
{
code: 'truenas_smart',
severity: 'warning',
summary: 'Device /dev/sdc has SMART test failures.',
},
],
alerts: [
{
id: 'smart-sdc',
type: 'smart',
level: 'warning',
// TrueNAS alert text can differ from the incident by its full stop.
message: 'Device /dev/sdc has SMART test failures',
},
],
} as Partial<Resource>);
const { getAllByText, getByText } = render(() => <ResourceDetailDrawer resource={resource} />);
expect(getByText('Needs attention')).toBeInTheDocument();
expect(
getByText('Pool archive is DEGRADED: one member of mirror-0 is faulted.'),
).toBeInTheDocument();
expect(getAllByText(/Device \/dev\/sdc has SMART test failures/)).toHaveLength(1);
});
});
@@ -123,7 +123,10 @@ describe('getResourceHealthIssuePresentation', () => {
it('keeps a non-matching detail in both `details` and `title`', () => {
const result = getResourceHealthIssuePresentation(
makeResource({ incidentSummary: 'Primary issue', incidentLabel: 'Secondary issue' }),
makeResource({
incidentSummary: 'Primary issue',
storage: { postureSummary: 'Secondary issue' },
}),
);
expect(result?.details).toStrictEqual(['Secondary issue']);
expect(result?.title).toBe('Primary issue · Secondary issue');
@@ -135,14 +138,28 @@ describe('getResourceHealthIssuePresentation', () => {
const result = getResourceHealthIssuePresentation(
makeResource({
incidentSummary: 'Alpha',
incidentLabel: 'Beta',
storage: { postureSummary: 'BETA' },
storage: { postureSummary: 'Beta', riskSummary: 'BETA' },
}),
);
expect(result?.details).toStrictEqual(['Beta']);
expect(result?.title).toBe('Alpha · Beta');
});
it('uses the category label only when nothing more specific exists', () => {
expect(
getResourceHealthIssuePresentation(
makeResource({
incidentSummary: 'Pool is degraded',
incidentLabel: 'Resource Health Issue',
}),
),
).toMatchObject({ primary: 'Pool is degraded', details: [] });
expect(
getResourceHealthIssuePresentation(makeResource({ incidentLabel: 'Capacity Pressure' }))
?.primary,
).toBe('Capacity Pressure');
});
it('skips whitespace-only summaries so they never become the primary', () => {
const result = getResourceHealthIssuePresentation(
makeResource({ incidentSummary: ' ', incidentLabel: 'Real issue' }),
@@ -59,4 +59,48 @@ describe('resource health presentation', () => {
expect(getResourceHealthIssuePresentation(resource)).toBeNull();
});
it('keeps every incident, not only the rollup, so the drawer lists them all', () => {
const pool = makeResource({
type: 'storage',
status: 'warning',
incidentSummary: 'Device /dev/sdc has SMART test failures.',
incidents: [
{
code: 'truenas_volume_status',
severity: 'warning',
summary: 'Pool archive is DEGRADED: one member of mirror-0 is faulted.',
},
{
code: 'truenas_smart',
severity: 'warning',
summary: 'Device /dev/sdc has SMART test failures.',
},
],
storage: { topology: 'pool', platform: 'truenas' } as Resource['storage'],
});
expect(getResourceHealthIssuePresentation(pool)).toMatchObject({
primary: 'Device /dev/sdc has SMART test failures.',
details: ['Pool archive is DEGRADED: one member of mirror-0 is faulted.'],
});
});
it('says why a read-only TrueNAS dataset is amber', () => {
const dataset = makeResource({
type: 'storage',
status: 'warning',
tags: ['truenas', 'dataset', 'zfs', 'state:readonly'],
storage: { topology: 'dataset', platform: 'truenas' } as Resource['storage'],
});
const share = makeResource({
type: 'network-share',
status: 'warning',
tags: ['truenas', 'state:locked'],
});
expect(getResourceHealthIssuePresentation(dataset)?.primary).toBe('Dataset is read-only');
// Only datasets read the dataset state tag; shares carry their own states.
expect(getResourceHealthIssuePresentation(share)).toBeNull();
});
});
@@ -1,4 +1,5 @@
import type { Resource, ResourceStorageRisk } from '@/types/resource';
import { getTrueNASDatasetStateSummary } from '@/utils/truenasDatasetState';
export interface ResourceHealthIssuePresentation {
primary: string;
@@ -65,7 +66,9 @@ export const getResourceHealthIssuePresentation = (
const summaries: string[] = [];
pushUnique(summaries, resource.incidentSummary);
pushUnique(summaries, resource.incidentLabel);
// The rollup names only the top incident, so the drawer would otherwise
// drop the rest (a pool carries its own alert and its disks' alerts).
for (const incident of resource.incidents ?? []) pushUnique(summaries, incident.summary);
pushUnique(summaries, resource.storage?.postureSummary);
pushUnique(summaries, resource.storage?.riskSummary);
@@ -85,6 +88,14 @@ export const getResourceHealthIssuePresentation = (
pushUnique(summaries, agent?.unraid?.rebuildSummary);
pushRiskReasons(summaries, agent?.unraid?.risk);
if (resource.storage?.topology === 'dataset') {
pushUnique(summaries, getTrueNASDatasetStateSummary(resource.tags));
}
// The rollup's category label (Resource Health Issue, Capacity Pressure)
// says less than any summary above, so it only stands in when there is none.
if (summaries.length === 0) pushUnique(summaries, resource.incidentLabel);
if (summaries.length === 0 || !ATTENTION_STATUSES.has(status)) {
return null;
}
@@ -0,0 +1,20 @@
// A read-only TrueNAS dataset raises no incident, so its state tag
// (datasetStateTag in internal/truenas/provider.go) is the only place the
// provider says why it is impaired. Locked and unmounted datasets raise
// incidents too, which callers prefer when present. Shares and apps carry
// their own state tags, so only dataset resources should be read this way.
const TRUENAS_DATASET_STATE_SUMMARIES: Record<string, string> = {
'state:locked': 'Dataset is locked',
'state:unmounted': 'Dataset is not mounted',
'state:readonly': 'Dataset is read-only',
};
export const getTrueNASDatasetStateSummary = (
tags: readonly string[] | undefined,
): string | null => {
for (const tag of tags ?? []) {
const summary = TRUENAS_DATASET_STATE_SUMMARIES[tag.trim().toLowerCase()];
if (summary) return summary;
}
return null;
};