refactor(recovery): group inventory by posture

This commit is contained in:
rcourtman
2026-03-27 22:25:14 +00:00
parent e691b5328a
commit a3fa842866
9 changed files with 161 additions and 31 deletions
@@ -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
@@ -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<ProtectionRollup[]>;
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<ProtectedInventoryGroup[]>(() => {
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<
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border">
<For each={visibleRollups()}>
{(rollup) => {
<For each={visibleGroupedRollups()}>
{(group) => (
<>
<TableRow class="bg-surface-alt">
<TableCell
colspan={5}
class="py-1 pr-3 pl-3 text-[12px] sm:text-sm font-semibold text-base-content"
>
<div class="flex items-center gap-2">
<span>{group.label}</span>
<span class="text-[10px] font-normal uppercase tracking-wide text-muted">
{group.items.length} item{group.items.length === 1 ? '' : 's'}
</span>
</div>
</TableCell>
</TableRow>
<For each={group.items}>
{(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<
</TableCell>
</TableRow>
);
}}
}}
</For>
</>
)}
</For>
</TableBody>
</Table>
@@ -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);
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "d35ad29a3d34047e8332e9a38238c4feef8204c854b13a8dae9d4ea4c0cd6bfb",
"source_hash": "610586b6afe868a59ea40e626d2d5af557bb144053f5a98dfa06ee7394b30625",
"build_inputs": [
"package.json",
"tsconfig.json",
+23 -3
View File
@@ -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;
+17 -10
View File
@@ -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 = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value">Loading\u2026</span></div>';
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value">Loading\u2026</span></div><div class="team-stat-card"><span class="team-stat-label">Invites</span><span class="team-stat-value">Ready</span></div>';
return;
}
if (entry.teamQuery.status === "error") {
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value team-stat-error">Needs attention</span></div>';
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value team-stat-error">Needs attention</span></div><div class="team-stat-card"><span class="team-stat-label">Fallback</span><span class="team-stat-value">Invite only</span></div>';
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 = "";
@@ -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(
@@ -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 = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value">Loading…</span></div>';
stats.innerHTML =
'<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value">Loading…</span></div>' +
'<div class="team-stat-card"><span class="team-stat-label">Invites</span><span class="team-stat-value">Ready</span></div>';
return;
}
if (entry.teamQuery.status === 'error') {
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value team-stat-error">Needs attention</span></div>';
stats.innerHTML =
'<div class="team-stat-card"><span class="team-stat-label">Roster</span><span class="team-stat-value team-stat-error">Needs attention</span></div>' +
'<div class="team-stat-card"><span class="team-stat-label">Fallback</span><span class="team-stat-value">Invite only</span></div>';
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;
}
@@ -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;