mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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;
|
||||
|
||||
+39
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+20
-3
@@ -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' }),
|
||||
|
||||
+44
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user