diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index fc40ba911..e81b64526 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -653,6 +653,11 @@ scan pattern in its first column. Protected-item rows should lead with a clear status cue, the primary item name, and compact badge-backed item/platform context instead of relying on recovery-only rails or plain-text metadata lines that make the table read like a report instead of an operational grid. +That same inventory surface should use structural grouping to surface posture, +not just local row styling. When the protected-item page mixes healthy and +problematic coverage, the table body should section attention items ahead of +healthy coverage so operators can scan the recovery estate the way they scan +other grouped monitoring tables in Pulse. That shared unified-resource dependency now also includes policy-governed resource metadata on the frontend decode path: storage and recovery surfaces that route through `frontend-modern/src/hooks/useUnifiedResources.ts` must diff --git a/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx b/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx index ff7e74112..3e727e01e 100644 --- a/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx @@ -50,6 +50,7 @@ import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown'; type ProtectedSortCol = 'item' | 'type' | 'platform' | 'lastBackup' | 'outcome'; type SortDir = 'asc' | 'desc'; +type ProtectedInventoryGroupKey = 'attention' | 'healthy'; interface RecoveryRollupSummary { total: number; @@ -58,6 +59,12 @@ interface RecoveryRollupSummary { neverSucceeded: number; } +interface ProtectedInventoryGroup { + key: ProtectedInventoryGroupKey; + label: string; + items: ProtectionRollup[]; +} + interface RecoveryProtectedInventorySectionProps { filteredRollups: Accessor; historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>; @@ -170,6 +177,38 @@ export const RecoveryProtectedInventorySection: Component< return sortedRollups().slice(start, start + PROTECTED_ITEMS_PAGE_SIZE); }); + const isAttentionRollup = (rollup: ProtectionRollup): boolean => { + const nowMs = Date.now(); + const outcome = normalizeRecoveryOutcome(rollup.lastOutcome); + if (outcome === 'failed' || outcome === 'warning' || outcome === 'unknown') return true; + if (isRecoveryRollupStale(rollup, nowMs)) return true; + const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0; + const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0; + return successMs <= 0 && attemptMs > 0; + }; + + const visibleGroupedRollups = createMemo(() => { + const attention: ProtectionRollup[] = []; + const healthy: ProtectionRollup[] = []; + + for (const rollup of visibleRollups()) { + if (isAttentionRollup(rollup)) { + attention.push(rollup); + } else { + healthy.push(rollup); + } + } + + const groups: ProtectedInventoryGroup[] = []; + if (attention.length > 0) { + groups.push({ key: 'attention', label: 'Needs Attention', items: attention }); + } + if (healthy.length > 0) { + groups.push({ key: 'healthy', label: 'Healthy Coverage', items: healthy }); + } + return groups; + }); + const pageStart = createMemo(() => sortedRollups().length === 0 ? 0 : (protectedPage() - 1) * PROTECTED_ITEMS_PAGE_SIZE + 1, ); @@ -399,8 +438,24 @@ export const RecoveryProtectedInventorySection: Component< - - {(rollup) => { + + {(group) => ( + <> + + +
+ {group.label} + + {group.items.length} item{group.items.length === 1 ? '' : 's'} + +
+
+
+ + {(rollup) => { const resourceIndex = props.resourcesById(); const label = getRecoveryRollupItemLabel(rollup, resourceIndex); const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0; @@ -544,7 +599,10 @@ export const RecoveryProtectedInventorySection: Component< ); - }} + }} + + + )}
diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx index 5ea005b4f..0f7207861 100644 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx +++ b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx @@ -205,6 +205,7 @@ describe('Recovery', () => { expect(screen.getByText('Recovery Posture')).toBeInTheDocument(); expect(await screen.findByRole('tab', { name: /protected items/i })).toBeInTheDocument(); await screen.findByText('VM 123'); + expect(screen.getByText('Needs Attention')).toBeInTheDocument(); expect(screen.queryByText('Recovery Events')).not.toBeInTheDocument(); await waitFor(() => { expect(screen.getAllByRole('table')).toHaveLength(1); diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 238f3206d..c1bbeb016 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "d35ad29a3d34047e8332e9a38238c4feef8204c854b13a8dae9d4ea4c0cd6bfb", + "source_hash": "610586b6afe868a59ea40e626d2d5af557bb144053f5a98dfa06ee7394b30625", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index a494025b4..6b318135d 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -1282,18 +1282,38 @@ header .logout-btn:hover, } .team-list-message { display: flex; - align-items: center; + flex-direction: column; + align-items: flex-start; justify-content: center; - min-height: 120px; - padding: 16px; + gap: 8px; + min-height: 144px; + padding: 18px; border: 1px dashed var(--line-strong); border-radius: 10px; color: var(--ink-muted); background: var(--panel-muted); } +.team-list-message-title { + font-size: 15px; + line-height: 1.15; + letter-spacing: -0.02em; + color: var(--ink); +} +.team-list-message-copy { + font-size: 13px; + line-height: 1.55; + color: var(--ink-soft); +} .team-list-message.error { + border-color: #efc1c1; + background: #fff4f4; +} +.team-list-message.error .team-list-message-title { color: var(--danger); } +.team-list-message.error .team-list-message-copy { + color: #8a4141; +} .btn-remove { padding: 0; background: none; diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 2b9668ad6..f4a5c7151 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -138,12 +138,19 @@ actionButton.setAttribute("data-workspace-action", workspace.state === "active" ? "suspend" : "delete"); closeButton.disabled = entry.manageWorkspace.pending; } - function setContainerMessage(container, msg, isError) { + function setContainerMessage(container, title, msg, isError) { container.textContent = ""; - var message = document.createElement("div"); - message.className = "team-list-message" + (isError ? " error" : ""); - message.textContent = msg; - container.appendChild(message); + var card = document.createElement("div"); + card.className = "team-list-message" + (isError ? " error" : ""); + var titleNode = document.createElement("strong"); + titleNode.className = "team-list-message-title"; + titleNode.textContent = title; + card.appendChild(titleNode); + var body = document.createElement("span"); + body.className = "team-list-message-copy"; + body.textContent = msg; + card.appendChild(body); + container.appendChild(card); } function countMembersByRole(members, role) { var count = 0; @@ -160,11 +167,11 @@ return; } if (entry.teamQuery.status === "loading") { - stats.innerHTML = '
RosterLoading\u2026
'; + stats.innerHTML = '
RosterLoading\u2026
InvitesReady
'; return; } if (entry.teamQuery.status === "error") { - stats.innerHTML = '
RosterNeeds attention
'; + stats.innerHTML = '
RosterNeeds attention
FallbackInvite only
'; return; } var members = entry.teamQuery.data; @@ -265,15 +272,15 @@ return; } if (entry.teamQuery.status === "loading") { - setContainerMessage(roster, "Loading\u2026", false); + setContainerMessage(roster, "Loading roster", "Checking who currently has access to this account.", false); return; } if (entry.teamQuery.status === "error") { - setContainerMessage(roster, entry.teamQuery.error, true); + setContainerMessage(roster, "Roster needs attention", entry.teamQuery.error, true); return; } if (!entry.teamQuery.data.length) { - setContainerMessage(roster, "No team members.", false); + setContainerMessage(roster, "No operators yet", "Invite someone new when this hosted account needs shared access.", false); return; } roster.textContent = ""; diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts index b07a5fb8e..aa1878d54 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts @@ -83,12 +83,13 @@ describe('account view', function() { teamVisible: true, teamQuery: { status: 'loading', error: '', data: [] }, })); - expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Loading'); + expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Loading roster'); renderTeamSection('acct_1', createEntry({ teamVisible: true, teamQuery: { status: 'error', error: 'Failed to load team.', data: [] }, })); + expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Roster needs attention'); expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Failed to load team.'); renderTeamSection( diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index 2f5ae96df..37eb84ab2 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -163,12 +163,22 @@ export function renderWorkspaceManagement(account: PortalAccountSummary, entry: closeButton.disabled = entry.manageWorkspace.pending; } -function setContainerMessage(container: HTMLElement, msg: string, isError: boolean): void { +function setContainerMessage(container: HTMLElement, title: string, msg: string, isError: boolean): void { container.textContent = ''; - var message = document.createElement('div'); - message.className = 'team-list-message' + (isError ? ' error' : ''); - message.textContent = msg; - container.appendChild(message); + var card = document.createElement('div'); + card.className = 'team-list-message' + (isError ? ' error' : ''); + + var titleNode = document.createElement('strong'); + titleNode.className = 'team-list-message-title'; + titleNode.textContent = title; + card.appendChild(titleNode); + + var body = document.createElement('span'); + body.className = 'team-list-message-copy'; + body.textContent = msg; + card.appendChild(body); + + container.appendChild(card); } function countMembersByRole(members: PortalTeamMember[], role: string): number { @@ -187,11 +197,15 @@ function renderTeamStats(accountID: string, entry: PortalAccountUIEntry): void { return; } if (entry.teamQuery.status === 'loading') { - stats.innerHTML = '
RosterLoading…
'; + stats.innerHTML = + '
RosterLoading…
' + + '
InvitesReady
'; return; } if (entry.teamQuery.status === 'error') { - stats.innerHTML = '
RosterNeeds attention
'; + stats.innerHTML = + '
RosterNeeds attention
' + + '
FallbackInvite only
'; return; } @@ -317,15 +331,15 @@ export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry return; } if (entry.teamQuery.status === 'loading') { - setContainerMessage(roster, 'Loading…', false); + setContainerMessage(roster, 'Loading roster', 'Checking who currently has access to this account.', false); return; } if (entry.teamQuery.status === 'error') { - setContainerMessage(roster, entry.teamQuery.error, true); + setContainerMessage(roster, 'Roster needs attention', entry.teamQuery.error, true); return; } if (!entry.teamQuery.data.length) { - setContainerMessage(roster, 'No team members.', false); + setContainerMessage(roster, 'No operators yet', 'Invite someone new when this hosted account needs shared access.', false); return; } diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index 7924525d2..577b733ef 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -1446,20 +1446,44 @@ header .logout-btn:hover, .team-list-message { display: flex; - align-items: center; + flex-direction: column; + align-items: flex-start; justify-content: center; - min-height: 120px; - padding: 16px; + gap: 8px; + min-height: 144px; + padding: 18px; border: 1px dashed var(--line-strong); border-radius: 10px; color: var(--ink-muted); background: var(--panel-muted); } +.team-list-message-title { + font-size: 15px; + line-height: 1.15; + letter-spacing: -0.02em; + color: var(--ink); +} + +.team-list-message-copy { + font-size: 13px; + line-height: 1.55; + color: var(--ink-soft); +} + .team-list-message.error { + border-color: #efc1c1; + background: #fff4f4; +} + +.team-list-message.error .team-list-message-title { color: var(--danger); } +.team-list-message.error .team-list-message-copy { + color: #8a4141; +} + .btn-remove { padding: 0; background: none;