mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
parent
0bfed25e45
commit
2fa271bbe9
5 changed files with 164 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue