mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
refactor(recovery): group inventory by posture
This commit is contained in:
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "d35ad29a3d34047e8332e9a38238c4feef8204c854b13a8dae9d4ea4c0cd6bfb",
|
||||
"source_hash": "610586b6afe868a59ea40e626d2d5af557bb144053f5a98dfa06ee7394b30625",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+23
-3
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user