diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index c16a13ec1d..b4a36fc791 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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; diff --git a/frontend-modern/browser-verification/4e0ee62e0d94a5dd.json b/frontend-modern/browser-verification/4e0ee62e0d94a5dd.json new file mode 100644 index 0000000000..dc78f0cf78 --- /dev/null +++ b/frontend-modern/browser-verification/4e0ee62e0d94a5dd.json @@ -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" + ] +} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 76fa724865..d58b0d7cb3 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -370,16 +370,27 @@ export const ResourceDetailDrawerOverviewTab: Component 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; diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx index 5ef3e9ff42..02a2120f33 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx @@ -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); + + const { getAllByText, getByText } = render(() => ); + + 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); + }); }); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceHealthPresentation.branchcov0712.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceHealthPresentation.branchcov0712.test.ts index 175b2b3e8c..406790d4c8 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceHealthPresentation.branchcov0712.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceHealthPresentation.branchcov0712.test.ts @@ -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' }), diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceHealthPresentation.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceHealthPresentation.test.ts index b9419ea04e..526c85dd60 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceHealthPresentation.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceHealthPresentation.test.ts @@ -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(); + }); }); diff --git a/frontend-modern/src/components/Infrastructure/resourceHealthPresentation.ts b/frontend-modern/src/components/Infrastructure/resourceHealthPresentation.ts index 64ddd65688..47fb76ad9c 100644 --- a/frontend-modern/src/components/Infrastructure/resourceHealthPresentation.ts +++ b/frontend-modern/src/components/Infrastructure/resourceHealthPresentation.ts @@ -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; } diff --git a/frontend-modern/src/utils/truenasDatasetState.ts b/frontend-modern/src/utils/truenasDatasetState.ts new file mode 100644 index 0000000000..c808a6be93 --- /dev/null +++ b/frontend-modern/src/utils/truenasDatasetState.ts @@ -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 = { + '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; +};