Stop redacting resource names in remaining operator-local UI

Continues the policy-redaction scope fix from abdde303a. The same wrong
invariant -- "operator-local UI redacts when policy says redact" -- was
also encoded in:

- alertResourceTableModel.getAlertResourceLabel (the /alerts table that
  feeds AlertResourceTableMobile and AlertResourceTableRow)
- AlertResourceIncidentsPanel (the incident card shown after the operator
  clicks into a resource)
- infrastructureSelectors.matchesSearch (/infrastructure search filter,
  whose haystack already includes raw hostname/ips so redacting the
  displayName was internally inconsistent)
- useResources filter().search (the shared resource hook's search
  predicate, used by Settings panels, /storage, /alerts, FindingsPanel)
- recoveryRecordPresentation.getRecoveryLinkedResourceLabel (recovery
  rollup labels rendered in /recovery)
- Settings/ResourcePicker (the operator's selection UI for the Reporting
  Panel; the picker is local even when the report itself transmits)

All six surfaces are operator-local. Per docs/PRIVACY.md, resource-policy
redaction applies "Before non-local model requests leave the instance" --
it is a transmission-boundary policy, not a local-rendering policy. The
operator must see their own resource names to recognize, search, and
configure them.

Switch each callsite to getPreferredInfrastructureDisplayName. Invert the
five test fixtures (ResourceTable, ResourcePicker, infrastructureSelectors,
UnifiedResourceTable performance contract, WorkloadsSurface performance
contract) that locked in the old redaction-in-local-UI invariant.

Cloud-bound callers (AI Chat, organization sharing prepare-payload, the
resource-detail drawer's governance meta panel) still call
getPreferredResourceDisplayName and continue to redact -- their tests
are unchanged. The only call site of the governed helper that is left
not yet reclassified is problemResourcePresentation, which currently has
no production consumers; leaving it untouched.
This commit is contained in:
rcourtman 2026-05-09 20:16:37 +01:00
parent 7637f1efac
commit a17f879a19
11 changed files with 51 additions and 36 deletions

View file

@ -205,7 +205,10 @@ describe('ResourceTable', () => {
expect(screen.getByText('my-vm-100')).toBeInTheDocument();
});
it('uses the governed label for policy-aware resources', () => {
it('renders policy-redacted resources with their raw display name in operator-local UI', () => {
// The alert resource table is local UI for the operator. Per docs/PRIVACY.md
// resource-policy redaction is a transmission-boundary policy, not a
// local-rendering policy.
const props = makeProps({
resources: [
makeResource({
@ -222,9 +225,9 @@ describe('ResourceTable', () => {
});
render(() => <ResourceTable {...props} />);
expect(screen.getByText('Production VM')).toBeInTheDocument();
expect(screen.getByLabelText('Edit thresholds for Production VM')).toBeInTheDocument();
expect(screen.queryByText('secret-vm-1')).not.toBeInTheDocument();
expect(screen.getByText('secret-vm-1')).toBeInTheDocument();
expect(screen.getByLabelText('Edit thresholds for secret-vm-1')).toBeInTheDocument();
expect(screen.queryByText('Production VM')).not.toBeInTheDocument();
});
it('renders multiple resources', () => {

View file

@ -1,5 +1,5 @@
import type { Resource as UnifiedResource, ResourcePolicy } from '@/types/resource';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
const COLUMN_TOOLTIP_LOOKUP: Record<string, string> = {
'cpu %': 'Percent CPU utilization allowed before an alert fires.',
@ -249,7 +249,9 @@ export function alertResourceSupportsMetric(
}
export function getAlertResourceLabel(resource: AlertResourceTableResourceLike): string {
return getPreferredResourceDisplayName(resource as unknown as UnifiedResource);
// Operator-local /alerts table; redaction is a transmission-boundary policy
// (docs/PRIVACY.md). Use the raw infra display name to match /infrastructure.
return getPreferredInfrastructureDisplayName(resource as unknown as UnifiedResource);
}
function parseAlertMetricNumber(value: unknown): number | undefined {

View file

@ -711,7 +711,8 @@ describe('UnifiedResourceTable performance contract', () => {
expect(filtered[0]?.platformData?.sources).toEqual(['proxmox']);
});
it('keeps governed resource search aligned with the infrastructure display label', () => {
it('searches policy-redacted resources by their raw infrastructure display name', () => {
// Local search; redaction is a transmission-boundary policy (docs/PRIVACY.md).
const governedResource = makeResource(9, {
name: 'secret-host-9',
displayName: 'secret-host-9',
@ -722,8 +723,8 @@ describe('UnifiedResourceTable performance contract', () => {
aiSafeSummary: 'Production Host',
});
expect(matchesSearch(governedResource, 'Production')).toBe(true);
expect(matchesSearch(governedResource, 'secret-host-9')).toBe(false);
expect(matchesSearch(governedResource, 'secret-host-9')).toBe(true);
expect(matchesSearch(governedResource, 'Production')).toBe(false);
});
it('suppresses non-blocking policy posture in host-table rows while preserving blocking policy badges', async () => {

View file

@ -61,7 +61,10 @@ describe('infrastructureSelectors', () => {
expect(matchesSearch(resource, 'NODE-123')).toBe(true);
});
it('matches governed resources by the safe display label instead of the redacted hostname', () => {
it('matches policy-redacted resources by their raw display name in operator-local search', () => {
// The /infrastructure search box is local UI; redaction is a transmission
// policy (docs/PRIVACY.md). Plus the haystack already includes raw
// hostname/ips, so the displayName must be raw too for consistency.
const governedResource = makeResource(2, {
name: 'secret-node-2',
displayName: 'secret-node-2',
@ -72,8 +75,8 @@ describe('infrastructureSelectors', () => {
aiSafeSummary: 'Production Node',
});
expect(matchesSearch(governedResource, 'Production')).toBe(true);
expect(matchesSearch(governedResource, 'secret-node-2')).toBe(false);
expect(matchesSearch(governedResource, 'secret-node-2')).toBe(true);
expect(matchesSearch(governedResource, 'Production')).toBe(false);
});
it('matches by ip and tag and returns false when missing', () => {

View file

@ -1,9 +1,6 @@
import type { Resource } from '@/types/resource';
import { getCpuPercent, getDiskPercent, getMemoryPercent } from '@/types/resource';
import {
getPreferredInfrastructureDisplayName,
getPreferredResourceDisplayName,
} from '@/utils/resourceIdentity';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
import { getInfrastructureSystemIdentitySortLabel } from '@/utils/resourceBadgePresentation';
import { normalizeSourcePlatformKey, type KnownSourcePlatform } from '@/utils/sourcePlatforms';
import { getCanonicalStatusLabel, STATUS_SORT_ORDER } from '@/utils/status';
@ -140,7 +137,9 @@ export const matchesSearch = (resource: Resource, term: string): boolean => {
if (!term) return true;
const normalizedTerm = term.toLowerCase();
const candidates: string[] = [
getPreferredResourceDisplayName(resource),
// Local search box; the operator types raw names. Haystack already includes
// raw hostname and ips, so the displayName must be raw too for consistency.
getPreferredInfrastructureDisplayName(resource),
resource.id,
resource.identity?.hostname ?? '',
...(resource.identity?.ips ?? []),

View file

@ -7,7 +7,7 @@ import { SearchField } from '@/components/shared/SearchField';
import { StatusDot } from '@/components/shared/StatusDot';
import { useResources } from '@/hooks/useResources';
import type { Resource, ResourceType } from '@/types/resource';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
import {
getResourcePickerEmptyState,
getResourcePickerTypeFilterLabel,
@ -58,7 +58,7 @@ export function ResourcePicker(props: ResourcePickerProps) {
const searchTerm = search().toLowerCase().trim();
if (searchTerm) {
result = result.filter((r) => {
const name = getPreferredResourceDisplayName(r).toLowerCase();
const name = getPreferredInfrastructureDisplayName(r).toLowerCase();
const id = r.id.toLowerCase();
return name.includes(searchTerm) || id.includes(searchTerm);
});
@ -75,8 +75,8 @@ export function ResourcePicker(props: ResourcePickerProps) {
const aOrder = reportableResourceTypeSortOrder(a.type);
const bOrder = reportableResourceTypeSortOrder(b.type);
if (aOrder !== bOrder) return aOrder - bOrder;
return getPreferredResourceDisplayName(a).localeCompare(
getPreferredResourceDisplayName(b),
return getPreferredInfrastructureDisplayName(a).localeCompare(
getPreferredInfrastructureDisplayName(b),
);
});
@ -101,7 +101,7 @@ export function ResourcePicker(props: ResourcePickerProps) {
{
id: resource.id,
type: resource.type,
name: getPreferredResourceDisplayName(resource),
name: getPreferredInfrastructureDisplayName(resource),
},
]);
}
@ -115,7 +115,7 @@ export function ResourcePicker(props: ResourcePickerProps) {
.map((r) => ({
id: r.id,
type: r.type,
name: getPreferredResourceDisplayName(r),
name: getPreferredInfrastructureDisplayName(r),
}));
const newSelection = [...current, ...toAdd];
@ -253,7 +253,7 @@ export function ResourcePicker(props: ResourcePickerProps) {
{/* Name and ID */}
<div class="flex-1 min-w-0">
<div class="text-sm text-white sm:truncate break-words">
{getPreferredResourceDisplayName(resource)}
{getPreferredInfrastructureDisplayName(resource)}
</div>
<div class="text-xs text-slate-500 sm:truncate break-all">
{resource.id}

View file

@ -100,7 +100,11 @@ describe('ResourcePicker', () => {
expect(screen.getByText('TrueNAS')).toBeInTheDocument();
});
it('renders governed resources with the safe display label', async () => {
it('renders policy-redacted resources with their raw display name (operator-local picker does not redact)', async () => {
// The Reporting Panel picker is the operator selecting their own resources.
// Per docs/PRIVACY.md, redaction is a transmission-boundary policy and must
// not be applied to local /settings UI; the operator needs the raw name to
// recognize the resource.
mockResources = [
makeResource({
id: 'vm-2',
@ -118,8 +122,8 @@ describe('ResourcePicker', () => {
renderPicker();
expect(await screen.findByText('Production VM')).toBeInTheDocument();
expect(screen.queryByText('secret-vm-2')).not.toBeInTheDocument();
expect(await screen.findByText('secret-vm-2')).toBeInTheDocument();
expect(screen.queryByText('Production VM')).not.toBeInTheDocument();
});
it('applies type filter buttons', async () => {

View file

@ -428,11 +428,13 @@ describe('Workloads performance contract', () => {
expect(mockUnifiedResourcesQuery).toContain('type=agent');
});
it('keeps governed resource search aligned with the preferred display label', () => {
it('searches policy-redacted resources by their raw display name in operator-local UI', () => {
// /workloads search; redaction is a transmission-boundary policy
// (docs/PRIVACY.md), so the haystack must use the raw infra name.
const resources = [makeResource()];
const filtered = filterResources(resources, new Set(), new Set(), ['Production']);
const rawFiltered = filterResources(resources, new Set(), new Set(), ['secret-host']);
const filtered = filterResources(resources, new Set(), new Set(), ['secret-host']);
const rawFiltered = filterResources(resources, new Set(), new Set(), ['Production']);
expect(filtered).toHaveLength(1);
expect(rawFiltered).toHaveLength(0);

View file

@ -10,7 +10,7 @@ import {
type ResourceSurfaceLink,
} from '@/routing/resourceLinks';
import type { Resource } from '@/types/resource';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
import {
getAlertIncidentLevelBadgeClass,
getAlertIncidentStatusPresentation,
@ -52,7 +52,7 @@ export function AlertResourceIncidentsPanel(props: AlertResourceIncidentsPanelPr
const resourceDisplayName = () => {
const current = resource();
if (current) {
return getPreferredResourceDisplayName(current);
return getPreferredInfrastructureDisplayName(current);
}
return selection().resourceName;
};

View file

@ -38,7 +38,7 @@ import {
getMemoryPercent,
getDiskPercent,
} from '@/types/resource';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
type ResourceStoreLike = Pick<ReturnType<typeof getGlobalWebSocketStore>, 'state'>;
export interface UseResourcesReturn {
@ -204,7 +204,8 @@ export function useResources(storeOverride?: ResourceStoreLike): UseResourcesRet
if (filter.search && filter.search.trim()) {
const term = filter.search.toLowerCase().trim();
result = result.filter((r) => {
const name = getPreferredResourceDisplayName(r).toLowerCase();
// Match the raw operator-facing name; redaction is a transmission policy.
const name = getPreferredInfrastructureDisplayName(r).toLowerCase();
const id = r.id.toLowerCase();
return name.includes(term) || id.includes(term);
});

View file

@ -2,7 +2,7 @@ import type { ProtectionRollup, RecoveryPoint } from '@/types/recovery';
import type { Resource } from '@/types/resource';
import { getRecoveryArtifactModePresentation } from '@/utils/recoveryArtifactModePresentation';
import { normalizeRecoveryOutcome } from '@/utils/recoveryOutcomePresentation';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
export type RecoveryArtifactMode = 'snapshot' | 'local' | 'remote';
@ -14,7 +14,7 @@ const getRecoveryLinkedResourceLabel = (
if (!itemResourceId) return '';
const resource = resourcesById.get(itemResourceId);
if (!resource) return '';
const label = getPreferredResourceDisplayName(resource).trim();
const label = getPreferredInfrastructureDisplayName(resource).trim();
if (!label) return '';
if (label.toLowerCase() === itemResourceId.toLowerCase()) return '';
return label;