Normalize recovery subject labels across rollups

This commit is contained in:
rcourtman 2026-03-24 19:46:00 +00:00
parent 0922685b01
commit ce9900218e
11 changed files with 271 additions and 23 deletions

View file

@ -284,6 +284,11 @@ That same direct proof rule also applies to the shared recovery record helper:
`frontend-modern/src/utils/recoveryRecordPresentation.ts` must stay on the
explicit `recovery-product-surface` proof path instead of inheriting coverage
only through pages or higher-level recovery components.
That shared recovery record contract now also includes rollup-side display
payload continuity: the recovery backend must preserve the latest normalized
subject label on rollups, and recovery UI helpers must prefer that canonical
display label before raw subject ids whenever the live unified-resource map is
missing or only resolves to opaque machine identifiers.
That same direct proof rule also applies to the shared recovery outcome helper:
`frontend-modern/src/utils/recoveryOutcomePresentation.ts` must stay on the
explicit `recovery-product-surface` proof path instead of inheriting coverage

View file

@ -144,6 +144,50 @@ describe('Recovery', () => {
expect(screen.getByText('tank/apps')).toBeInTheDocument();
});
it('renders canonical rollup and history subject labels when linked resources are unavailable', async () => {
rollupsPayload.push({
rollupId: 'res:vm-404',
subjectResourceId: 'vm-404',
display: { subjectLabel: 'Archive VM' },
lastAttemptAt: '2026-02-12T08:00:00.000Z',
lastSuccessAt: '2026-02-12T08:00:00.000Z',
lastOutcome: 'success',
providers: ['proxmox-pve'],
});
pointsByRollupId['res:vm-404'] = [
{
id: 'p404',
provider: 'proxmox-pve',
kind: 'backup',
mode: 'local',
outcome: 'success',
completedAt: '2026-02-12T08:00:00.000Z',
display: { subjectLabel: 'Archive VM' },
},
];
try {
render(() => <Recovery />);
const subject = await screen.findByText('Archive VM');
fireEvent.click(subject);
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith('/recovery?rollupId=res%3Avm-404', {
replace: true,
});
});
const tables = await screen.findAllByRole('table');
const historyTable = tables[tables.length - 1];
expect(within(historyTable).getByText('Archive VM')).toBeInTheDocument();
expect(within(historyTable).queryByText('vm-404')).not.toBeInTheDocument();
} finally {
rollupsPayload.pop();
delete pointsByRollupId['res:vm-404'];
}
});
it('focuses history when a rollup is clicked', async () => {
render(() => <Recovery />);

View file

@ -75,6 +75,7 @@ export interface ProtectionRollup {
rollupId: string;
subjectResourceId?: string;
subjectRef?: RecoveryExternalRef | null;
display?: RecoveryPointDisplay | null;
lastAttemptAt?: string | null;
lastSuccessAt?: string | null;

View file

@ -43,6 +43,42 @@ describe('recoveryRecordPresentation', () => {
expect(getRecoveryPointDetailsSummary(point)).toBe('Immutable and encrypted');
});
it('prefers governed display labels over opaque linked-resource ids', () => {
const resources = new Map<string, Resource>([
[
'res-2',
{
id: 'res-2',
name: 'res-2',
displayName: 'Payments API',
type: 'vm',
} as Resource,
],
[
'res-3',
{
id: 'res-3',
name: 'res-3',
type: 'vm',
} as Resource,
],
]);
const rollup = {
rollupId: 'res:res-3',
subjectResourceId: 'res-3',
display: { subjectLabel: 'Archive VM' },
} as ProtectionRollup;
const linkedPoint = {
id: 'point-3',
subjectResourceId: 'res-2',
display: { subjectLabel: 'billing-api' },
} as RecoveryPoint;
expect(getRecoveryRollupSubjectLabel(rollup, resources)).toBe('Archive VM');
expect(getRecoveryPointSubjectLabel(linkedPoint, resources)).toBe('Payments API');
});
it('derives timestamps and normalizes mode query values', () => {
const point = {
completedAt: '2026-03-09T12:00:00.000Z',

View file

@ -1,18 +1,31 @@
import type { ProtectionRollup, RecoveryPoint } from '@/types/recovery';
import type { Resource } from '@/types/resource';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
export type RecoveryArtifactMode = 'snapshot' | 'local' | 'remote';
const getRecoveryLinkedResourceLabel = (
subjectResourceId: string,
resourcesById: Map<string, Resource>,
): string => {
if (!subjectResourceId) return '';
const resource = resourcesById.get(subjectResourceId);
if (!resource) return '';
const label = getPreferredResourceDisplayName(resource).trim();
if (!label) return '';
if (label.toLowerCase() === subjectResourceId.toLowerCase()) return '';
return label;
};
export function getRecoveryRollupSubjectLabel(
rollup: ProtectionRollup,
resourcesById: Map<string, Resource>,
): string {
const subjectResourceId = (rollup.subjectResourceId || '').trim();
if (subjectResourceId) {
const resource = resourcesById.get(subjectResourceId);
const name = (resource?.name || '').trim();
if (name) return name;
}
const displayLabel = String(rollup.display?.subjectLabel || '').trim();
const linkedResourceLabel = getRecoveryLinkedResourceLabel(subjectResourceId, resourcesById);
if (linkedResourceLabel) return linkedResourceLabel;
if (displayLabel) return displayLabel;
const ref = rollup.subjectRef || null;
if (ref?.namespace && ref?.name) return `${ref.namespace}/${ref.name}`;
@ -32,14 +45,9 @@ export function getRecoveryPointSubjectLabel(
resourcesById: Map<string, Resource>,
): string {
const subjectResourceId = (point.subjectResourceId || '').trim();
if (subjectResourceId) {
const resource = resourcesById.get(subjectResourceId);
const name = (resource?.name || '').trim();
if (name) return name;
return subjectResourceId;
}
const displayLabel = String(point.display?.subjectLabel || '').trim();
const linkedResourceLabel = getRecoveryLinkedResourceLabel(subjectResourceId, resourcesById);
if (linkedResourceLabel) return linkedResourceLabel;
if (displayLabel) return displayLabel;
const ref = point.subjectRef || null;
@ -49,6 +57,7 @@ export function getRecoveryPointSubjectLabel(
if (name) return name;
const id = String(ref?.id || '').trim();
if (id) return id;
if (subjectResourceId) return subjectResourceId;
return point.id;
}

View file

@ -113,6 +113,10 @@ type ProtectionRollup struct {
// Latest known subject identity for display in UIs.
SubjectResourceID string `json:"subjectResourceId,omitempty"`
SubjectRef *ExternalRef `json:"subjectRef,omitempty"`
// Display mirrors the latest normalized subject-facing labels derived from
// the newest point in the rollup so protected-inventory UIs can prefer the
// canonical label contract before falling back to opaque ids.
Display *RecoveryPointDisplay `json:"display,omitempty"`
LastAttemptAt *time.Time `json:"lastAttemptAt,omitempty"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`

View file

@ -838,3 +838,53 @@ func TestBuildRollupsFromPoints_SortOrder(t *testing.T) {
t.Error("expected rollups sorted by last attempt descending")
}
}
func TestBuildRollupsFromPoints_PreservesLatestDisplayLabels(t *testing.T) {
now := time.Now()
laterTime := now.Add(1 * time.Minute)
points := []RecoveryPoint{
{
ID: "p1",
Provider: ProviderProxmoxPVE,
SubjectResourceID: "res-1",
StartedAt: &now,
CompletedAt: &now,
Outcome: OutcomeSuccess,
Display: &RecoveryPointDisplay{
SubjectLabel: "Old Label",
SubjectType: "proxmox-vm",
},
},
{
ID: "p2",
Provider: ProviderProxmoxPVE,
SubjectResourceID: "res-1",
StartedAt: &laterTime,
CompletedAt: &laterTime,
Outcome: OutcomeFailed,
Display: &RecoveryPointDisplay{
SubjectLabel: "Current Label",
SubjectType: "proxmox-vm",
IsWorkload: true,
},
},
}
rollups := BuildRollupsFromPoints(points)
if len(rollups) != 1 {
t.Fatalf("expected 1 rollup, got %d", len(rollups))
}
if rollups[0].Display == nil {
t.Fatal("expected rollup display to be populated")
}
if got := rollups[0].Display.SubjectLabel; got != "Current Label" {
t.Fatalf("Display.SubjectLabel = %q, want %q", got, "Current Label")
}
if got := rollups[0].Display.SubjectType; got != "proxmox-vm" {
t.Fatalf("Display.SubjectType = %q, want %q", got, "proxmox-vm")
}
if !rollups[0].Display.IsWorkload {
t.Fatal("expected rollup display workload marker to be preserved")
}
}

View file

@ -6,6 +6,14 @@ import (
"time"
)
func displayForRollupPoint(p RecoveryPoint) *RecoveryPointDisplay {
if p.Display != nil {
display := *p.Display
return &display
}
return DeriveIndex(p).ToDisplay()
}
// BuildRollupsFromPoints computes per-subject rollups from a set of recovery points.
// This mirrors the sqlite rollup semantics (timestamp selection + success window)
// so mock mode and in-memory consumers behave consistently with persisted stores.
@ -28,6 +36,7 @@ func BuildRollupsFromPoints(points []RecoveryPoint) []ProtectionRollup {
// Latest identity seen (ties resolved by latestTS/updated/id).
subjectRID string
subjectRef *ExternalRef
display *RecoveryPointDisplay
providers map[Provider]struct{}
}
@ -90,6 +99,7 @@ func BuildRollupsFromPoints(points []RecoveryPoint) []ProtectionRollup {
lastSuccessMs: 0,
subjectRID: strings.TrimSpace(p.SubjectResourceID),
subjectRef: p.SubjectRef,
display: displayForRollupPoint(p),
providers: make(map[Provider]struct{}, 2),
}
byKey[subjectKey] = a
@ -119,6 +129,7 @@ func BuildRollupsFromPoints(points []RecoveryPoint) []ProtectionRollup {
}
a.subjectRID = strings.TrimSpace(p.SubjectResourceID)
a.subjectRef = p.SubjectRef
a.display = displayForRollupPoint(p)
}
}
@ -148,6 +159,7 @@ func BuildRollupsFromPoints(points []RecoveryPoint) []ProtectionRollup {
RollupID: strings.TrimSpace(a.subjectKey),
SubjectResourceID: a.subjectRID,
SubjectRef: a.subjectRef,
Display: a.display,
LastAttemptAt: lastAttemptAt,
LastSuccessAt: lastSuccessAt,
LastOutcome: a.latestOutcome,

View file

@ -1077,6 +1077,15 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions
subject_key,
subject_resource_id,
subject_ref_json,
subject_label,
subject_type,
is_workload,
cluster_label,
node_host_label,
namespace_label,
entity_id_label,
repository_label,
details_summary,
provider,
outcome,
` + tsExpr + ` AS ts_ms,
@ -1101,12 +1110,33 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions
FROM filtered
),
latest AS (
SELECT subject_key, subject_resource_id, subject_ref_json
SELECT
subject_key,
subject_resource_id,
subject_ref_json,
subject_label,
subject_type,
is_workload,
cluster_label,
node_host_label,
namespace_label,
entity_id_label,
repository_label,
details_summary
FROM (
SELECT
subject_key,
subject_resource_id,
subject_ref_json,
subject_label,
subject_type,
is_workload,
cluster_label,
node_host_label,
namespace_label,
entity_id_label,
repository_label,
details_summary,
ROW_NUMBER() OVER (PARTITION BY subject_key ORDER BY ts_ms DESC, updated_at_ms DESC, id DESC) AS rn
FROM filtered
) x
@ -1121,6 +1151,15 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions
agg.subject_key,
latest.subject_resource_id,
latest.subject_ref_json,
latest.subject_label,
latest.subject_type,
latest.is_workload,
latest.cluster_label,
latest.node_host_label,
latest.namespace_label,
latest.entity_id_label,
latest.repository_label,
latest.details_summary,
agg.last_attempt_ms,
agg.last_success_ms,
(SELECT outcome FROM ranked r WHERE r.subject_key = agg.subject_key AND r.rn = 1) AS last_outcome,
@ -1144,12 +1183,38 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions
var subjectKey string
var subjectRID sql.NullString
var subjectRefRaw sql.NullString
var subjectLabel sql.NullString
var subjectType sql.NullString
var isWorkload sql.NullInt64
var clusterLabel sql.NullString
var nodeHostLabel sql.NullString
var namespaceLabel sql.NullString
var entityIDLabel sql.NullString
var repositoryLabel sql.NullString
var detailsSummary sql.NullString
var lastAttemptMs sql.NullInt64
var lastSuccessMs sql.NullInt64
var lastOutcome string
var providersRaw sql.NullString
if err := rows.Scan(&subjectKey, &subjectRID, &subjectRefRaw, &lastAttemptMs, &lastSuccessMs, &lastOutcome, &providersRaw); err != nil {
if err := rows.Scan(
&subjectKey,
&subjectRID,
&subjectRefRaw,
&subjectLabel,
&subjectType,
&isWorkload,
&clusterLabel,
&nodeHostLabel,
&namespaceLabel,
&entityIDLabel,
&repositoryLabel,
&detailsSummary,
&lastAttemptMs,
&lastSuccessMs,
&lastOutcome,
&providersRaw,
); err != nil {
return nil, 0, err
}
@ -1189,10 +1254,23 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions
outcome = recovery.OutcomeUnknown
}
display := recovery.PointIndex{
SubjectLabel: strings.TrimSpace(subjectLabel.String),
SubjectType: strings.TrimSpace(subjectType.String),
IsWorkload: isWorkload.Valid && isWorkload.Int64 != 0,
ClusterLabel: strings.TrimSpace(clusterLabel.String),
NodeHostLabel: strings.TrimSpace(nodeHostLabel.String),
NamespaceLabel: strings.TrimSpace(namespaceLabel.String),
EntityIDLabel: strings.TrimSpace(entityIDLabel.String),
RepositoryLabel: strings.TrimSpace(repositoryLabel.String),
DetailsSummary: strings.TrimSpace(detailsSummary.String),
}.ToDisplay()
out = append(out, recovery.ProtectionRollup{
RollupID: strings.TrimSpace(subjectKey),
SubjectResourceID: strings.TrimSpace(subjectRID.String),
SubjectRef: subjectRefPtr,
Display: display,
LastAttemptAt: lastAttemptAt,
LastSuccessAt: lastSuccessAt,
LastOutcome: outcome,

View file

@ -93,6 +93,9 @@ func TestStore_ListRollups(t *testing.T) {
if got[0].SubjectRef == nil || got[0].SubjectRef.Type != "truenas-dataset" || got[0].SubjectRef.Name != "tank/apps" {
t.Fatalf("rollup[0].SubjectRef = %#v, want truenas dataset ref", got[0].SubjectRef)
}
if got[0].Display == nil || got[0].Display.SubjectLabel != "tank/apps" {
t.Fatalf("rollup[0].Display = %#v, want subject label tank/apps", got[0].Display)
}
// Second: vm-1 with latest failure at t2 and last success at t1.
if got[1].RollupID != "res:vm-1" {
@ -104,6 +107,9 @@ func TestStore_ListRollups(t *testing.T) {
if got[1].SubjectRef != nil {
t.Fatalf("rollup[1].SubjectRef = %#v, want nil", got[1].SubjectRef)
}
if got[1].Display == nil || got[1].Display.SubjectLabel != "vm-1" {
t.Fatalf("rollup[1].Display = %#v, want subject label vm-1", got[1].Display)
}
if got[1].LastAttemptAt == nil || !got[1].LastAttemptAt.Equal(t2) {
t.Fatalf("rollup[1].LastAttemptAt = %v, want %v", got[1].LastAttemptAt, t2)
}
@ -196,4 +202,7 @@ func TestStore_ListRollups(t *testing.T) {
if got3[0].RollupID != "res:pod-1" {
t.Fatalf("rollup with normalized filters = %q, want %q", got3[0].RollupID, "res:pod-1")
}
if got3[0].Display == nil || got3[0].Display.SubjectLabel != "default/pod-1" {
t.Fatalf("rollup with normalized filters display = %#v, want subject label default/pod-1", got3[0].Display)
}
}

View file

@ -3035,17 +3035,21 @@ index 1111111..2222222 100644
"allow_same_subsystem_tests": False,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Dashboard/GuestDrawer.test.tsx",
"frontend-modern/src/components/Dashboard/MetricBar.test.tsx",
"frontend-modern/src/components/Dashboard/StackedMemoryBar.test.tsx",
"frontend-modern/src/components/Dashboard/ThresholdSlider.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/Dashboard.performance.contract.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/DashboardFilter.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/DiskList.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/EnhancedCPUBar.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/GuestRow.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/StackedDiskBar.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/dashboardSelectionModel.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadFilterConfigModel.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadRouteModel.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadRouteStateModel.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/dashboardWorkloadUrlSyncModel.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/DiskList.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/EnhancedCPUBar.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/GuestRow.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/StackedDiskBar.test.tsx",
"frontend-modern/src/components/Dashboard/__tests__/useDashboardFilterState.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/useDashboardSelectionState.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/useDashboardWorkloadViewportSync.test.tsx",
@ -3057,12 +3061,8 @@ index 1111111..2222222 100644
"frontend-modern/src/components/Dashboard/__tests__/useThresholdSliderState.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/workloadSelectors.test.ts",
"frontend-modern/src/components/Dashboard/__tests__/workloadTopology.test.ts",
"frontend-modern/src/components/Dashboard/GuestDrawer.test.tsx",
"frontend-modern/src/components/Dashboard/MetricBar.test.tsx",
"frontend-modern/src/components/Dashboard/StackedMemoryBar.test.tsx",
"frontend-modern/src/components/Dashboard/ThresholdSlider.test.tsx",
"frontend-modern/src/components/Infrastructure/__tests__/infrastructureSummaryModel.test.ts",
"frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx",
"frontend-modern/src/components/Infrastructure/__tests__/infrastructureSummaryModel.test.ts",
"frontend-modern/src/components/Infrastructure/__tests__/unifiedResourceTableStateModel.test.ts",
"frontend-modern/src/components/Workloads/WorkloadsSummary.test.tsx",
"frontend-modern/src/utils/__tests__/thresholdSliderPresentation.test.ts",