Fix storage primary issue impact handling

Refs #423
This commit is contained in:
rcourtman 2026-05-04 18:42:09 +01:00
parent 0bfed25e45
commit 2fa271bbe9
5 changed files with 164 additions and 9 deletions

View file

@ -570,6 +570,12 @@ frontend primitive boundary.
identity better explains what the operator is looking at.
6. Keep Proxmox deep-link route selection on the shared settings-navigation boundary. `frontend-modern/src/components/Settings/settingsNavigationModel.ts` and `frontend-modern/src/components/Settings/useSettingsNavigation.ts` must treat the canonical PBS and PMG Proxmox deep links as agent-selection authority even though those URLs resolve to the shared `infrastructure-operations` tab. Reloading or remounting on a PBS or PMG deep link must not silently fall back to the PVE selector state.
7. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches.
Those reusable storage presenters must also keep primary issue copy separate
from contextual impact copy. Composite posture fields may include dependent
resource or protected workload impact, but shared table/presenter primitives
must derive primary issue labels and summaries from explicit incidents,
storage risk summaries, or storage-risk reasons so healthy rows do not
render impact text as a warning.
8. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.generated.ts` must be the tracked frontend projection of `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json`, `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, and `frontend-modern/src/utils/sourcePlatformOptions.ts` must consume that generated projection instead of embedding divergent future-label lists, setup/onboarding path allowlists, or presentation-only guesses, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest. The generic `docker` source-platform label is "Docker / Podman" in shared selectors, badges, and filter options so v5 Docker users can find the runtime surface while Podman-backed rows are not mislabeled as Docker-only; "Container runtime" remains the governed platform family, not the primary customer-facing label.
9. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. When a page keeps summary charts visible below the desktop breakpoint, it must use the shared `stickyDesktopOnly` mode instead of adding page-local media queries, so wrapped two-column summaries scroll as normal content and only become sticky once the large-screen layout is active. The shared summary-card contract must also own stable summary-card geometry for chart-backed cards so row hover, focus, synchronized readouts, or idle header metadata cannot ratchet the sticky summary taller across rerenders.
10. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.

View file

@ -167,6 +167,13 @@ handling must not duplicate those lifecycle events.
must pass resolved thresholds from the alerts activation boundary into the
shared metric-color helper instead of carrying storage-local usage color
bands.
Storage primary-issue presentation must stay incident/risk-owned. Composite
posture summaries from unified resources may include dependency or protected
workload impact, so `frontend-modern/src/features/storageBackups/` must not
use `storage.postureSummary` or `pbs.postureSummary` as primary issue copy
for healthy resources. Dependency impact belongs to impact summaries and
detail context, while `Primary Issue` must derive from explicit incidents,
storage risk summaries, or storage-risk reasons.
4. Route transport changes for storage and recovery endpoints through `internal/api/` and the owning `api-contracts` proof routes
Shared API-token transport helpers may be consumed by storage/recovery-
adjacent flows, but `owner_user_id` remains server-authored token identity

View file

@ -61,9 +61,9 @@ describe('resourceStoragePresentation', () => {
),
).toBe('Datastore');
expect(getResourceStorageTopologyLabel(makeResource(), 'rbd')).toBe('Cluster Storage');
expect(
getResourceStorageTopologyLabel(makeResource(), 'ignored', 'rebuild target'),
).toBe('Rebuild Target');
expect(getResourceStorageTopologyLabel(makeResource(), 'ignored', 'rebuild target')).toBe(
'Rebuild Target',
);
});
it('derives canonical issue, impact, action, and protection summaries', () => {
@ -109,6 +109,67 @@ describe('resourceStoragePresentation', () => {
expect(getResourceStorageProtectionLabel(resource)).toBe('Mirrored Cache');
});
it('keeps dependent impact out of primary issue copy for healthy storage', () => {
const impact = 'Affects 2 dependent resources: pulse, tailscale-pve3';
const resource = makeResource({
status: 'online',
storage: {
consumerCount: 2,
consumerImpactSummary: impact,
postureSummary: impact,
},
});
expect(getResourceStorageIssueLabel(resource)).toBe('Healthy');
expect(getResourceStorageIssueSummary(resource)).toBe('');
expect(getResourceStorageImpactSummary(resource)).toBe(impact);
});
it('uses canonical risk copy as the primary issue when posture also carries impact', () => {
const resource = makeResource({
status: 'degraded',
storage: {
riskSummary: 'ZFS pool tank is DEGRADED',
consumerImpactSummary: 'Affects 2 dependent resources: app01, media01',
postureSummary: 'ZFS pool tank is DEGRADED. Affects 2 dependent resources: app01, media01',
},
});
expect(getResourceStorageIssueLabel(resource)).toBe('ZFS pool tank is DEGRADED');
expect(getResourceStorageIssueSummary(resource)).toBe('ZFS pool tank is DEGRADED');
expect(getResourceStorageImpactSummary(resource)).toBe(
'Affects 2 dependent resources: app01, media01',
);
});
it('falls back to PBS storage-risk reasons without treating backup impact as an issue', () => {
const resource = makeResource({
type: 'pbs',
status: 'degraded',
pbs: {
postureSummary: 'Puts backups for 3 protected workloads at risk: app01, db01, media01',
protectedWorkloadSummary:
'Puts backups for 3 protected workloads at risk: app01, db01, media01',
storageRisk: {
level: 'warning',
reasons: [
{
code: 'pbs_datastore_state',
severity: 'warning',
summary: 'Backup datastore archive is degraded',
},
],
},
},
});
expect(getResourceStorageIssueLabel(resource)).toBe('Backup datastore archive is degraded');
expect(getResourceStorageIssueSummary(resource)).toBe('Backup datastore archive is degraded');
expect(getResourceStorageImpactSummary(resource)).toBe(
'Puts backups for 3 protected workloads at risk: app01, db01, media01',
);
});
it('keeps VMware datastore protection copy neutral on the shared storage path', () => {
const resource = makeResource({
platformType: 'vmware-vsphere',

View file

@ -345,6 +345,33 @@ describe('storageAdapters', () => {
expect(records[0].details?.protectionReduced).toBe(true);
});
it('keeps dependency impact separate from healthy storage primary issues', () => {
const records = buildStorageRecords({
state: baseState(),
resources: [
makeResourceStorage({
id: 'storage-with-consumers',
name: 'storage1',
status: 'online',
storage: {
type: 'zfspool',
platform: 'proxmox-pve',
topology: 'pool',
consumerCount: 2,
consumerImpactSummary: 'Affects 2 dependent resources: pulse, tailscale-pve3',
postureSummary: 'Affects 2 dependent resources: pulse, tailscale-pve3',
},
}),
],
});
expect(records).toHaveLength(1);
expect(records[0].health).toBe('healthy');
expect(records[0].issueLabel).toBe('Healthy');
expect(records[0].issueSummary).toBe('');
expect(records[0].impactSummary).toBe('Affects 2 dependent resources: pulse, tailscale-pve3');
});
it('maps VMware datastores as shared storage inventory instead of backup repositories', () => {
const records = buildStorageRecords({
state: baseState(),

View file

@ -3,6 +3,11 @@ import { getSourcePlatformLabel, normalizeSourcePlatformKey } from '@/utils/sour
import type { StorageBackupPlatform } from './models';
import { isBackupRepositoryStorageResource } from './resourceStorageMapping';
type StorageRiskLike = {
level?: string;
reasons?: { summary?: string }[];
};
const titleize = (value: string | undefined | null): string =>
(value || '')
.split(/[\s_-]+/)
@ -10,6 +15,53 @@ const titleize = (value: string | undefined | null): string =>
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
const trimSummary = (value: string | undefined | null): string => (value || '').trim();
const firstRiskReasonSummary = (risk: StorageRiskLike | undefined): string => {
if (!risk?.reasons?.length) return '';
return risk.reasons.map((reason) => trimSummary(reason.summary)).find(Boolean) || '';
};
const getResourceStorageRiskIssue = (resource: Resource): string =>
trimSummary(resource.storage?.riskSummary) ||
firstRiskReasonSummary(resource.storage?.risk) ||
firstRiskReasonSummary(resource.pbs?.storageRisk);
const isAttentionStatus = (value: string | undefined): boolean => {
const normalized = (value || '').trim().toLowerCase();
if (!normalized) return false;
return (
normalized === 'warning' ||
normalized === 'warn' ||
normalized === 'degraded' ||
normalized === 'critical' ||
normalized === 'faulted' ||
normalized === 'failed' ||
normalized === 'error' ||
normalized === 'unhealthy' ||
normalized === 'offline' ||
normalized === 'down' ||
normalized === 'unavailable'
);
};
const getCompositePostureIssue = (resource: Resource): string => {
const posture =
trimSummary(resource.storage?.postureSummary) || trimSummary(resource.pbs?.postureSummary);
if (!posture || !isAttentionStatus(resource.status)) return '';
const impactSummaries = [
resource.storage?.consumerImpactSummary,
resource.pbs?.protectedWorkloadSummary,
resource.pbs?.affectedDatastoreSummary,
]
.map(trimSummary)
.filter(Boolean);
if (impactSummaries.some((summary) => summary === posture)) return '';
return posture;
};
export const getCanonicalStoragePlatformKey = (
resource: Resource,
storagePlatform?: string,
@ -90,17 +142,19 @@ export const getResourceStorageTopologyLabel = (
export const getResourceStorageIssueLabel = (resource: Resource): string => {
if (resource.incidentLabel?.trim()) return resource.incidentLabel.trim();
if (resource.storage?.postureSummary?.trim()) return resource.storage.postureSummary.trim();
if (resource.storage?.riskSummary?.trim()) return resource.storage.riskSummary.trim();
if (resource.pbs?.postureSummary?.trim()) return resource.pbs.postureSummary.trim();
const riskIssue = getResourceStorageRiskIssue(resource);
if (riskIssue) return riskIssue;
const postureIssue = getCompositePostureIssue(resource);
if (postureIssue) return postureIssue;
return 'Healthy';
};
export const getResourceStorageIssueSummary = (resource: Resource): string => {
if (resource.incidentSummary?.trim()) return resource.incidentSummary.trim();
if (resource.storage?.riskSummary?.trim()) return resource.storage.riskSummary.trim();
if (resource.storage?.postureSummary?.trim()) return resource.storage.postureSummary.trim();
if (resource.pbs?.postureSummary?.trim()) return resource.pbs.postureSummary.trim();
const riskIssue = getResourceStorageRiskIssue(resource);
if (riskIssue) return riskIssue;
const postureIssue = getCompositePostureIssue(resource);
if (postureIssue) return postureIssue;
return '';
};