Stop redacting resource names in operator-local alert UI

Alert threshold tables, override pickers, and incident panels were
running every resource name through the policy-governed display helper.
A Tower-policied row read "agent (warning)" in /settings -> Alerts ->
Thresholds even though the same resource read "Tower" on /infrastructure
and /storage Pools, which already use the raw infrastructure display
name.

Per docs/PRIVACY.md, resource-policy redaction applies "Before non-local
model requests leave the instance" -- it gates AI-bound, cross-org, and
relay paths, not the operator's own browser. Redacting in local /settings
UI was inconsistent with the policy intent and with the rest of the
operator surface.

Switch the alert-resource label helper to
getPreferredInfrastructureDisplayName so threshold rows match
Infrastructure and Storage. Drop the now-dead governance gate inside
getFriendlyAlertNodeName (and the unused policy parameter all three
threshold hooks were threading through). Invert the six ThresholdsTable
test fixtures plus the alerts-helpers test to assert the new local-UI
invariant.

Cloud-bound surfaces (AI Chat, organization sharing, the resource-detail
drawer's governance meta panel) still call getPreferredResourceDisplayName
and continue to redact -- their tests are unchanged.
This commit is contained in:
rcourtman 2026-05-09 20:04:50 +01:00
parent b2dfae0715
commit abdde303ac
6 changed files with 46 additions and 30 deletions

View file

@ -345,7 +345,11 @@ describe('ThresholdsTable Resource Rendering', () => {
expect(screen.getByTestId('resource-name-h1')).toHaveTextContent('Host 1');
});
it('renders governed systems with the policy-aware display label', async () => {
it('renders policy-redacted systems with their raw display name (operator-local UI does not redact)', async () => {
// Resource-policy redaction is a transmission-boundary policy (docs/PRIVACY.md):
// it gates what leaves the instance toward non-local model providers, not the
// operator's own browser. The Threshold table must show the same name the
// operator sees on /infrastructure.
setPathname('/alerts/thresholds/systems');
const host = {
id: 'h2',
@ -367,8 +371,8 @@ describe('ThresholdsTable Resource Rendering', () => {
expect(screen.getByTestId('resource-table-Systems')).toBeInTheDocument();
});
expect(screen.getByTestId('resource-name-h2')).toHaveTextContent('redacted by policy');
expect(screen.getByTestId('resource-name-h2')).not.toHaveTextContent('secret-host');
expect(screen.getByTestId('resource-name-h2')).toHaveTextContent('Secret Host');
expect(screen.getByTestId('resource-name-h2')).not.toHaveTextContent('redacted by policy');
});
it('renders TrueNAS appliances on the canonical systems tab with their disk surface', async () => {
@ -431,7 +435,7 @@ describe('ThresholdsTable Resource Rendering', () => {
expect(screen.getByTestId('resource-name-guest1')).toHaveTextContent('vm1');
});
it('renders governed guests with the policy-aware display label', async () => {
it('renders policy-redacted guests with their raw display name in operator-local UI', async () => {
setPathname('/alerts/thresholds/infrastructure');
const guest = {
id: 'guest2',
@ -453,11 +457,11 @@ describe('ThresholdsTable Resource Rendering', () => {
expect(screen.getByTestId('section-VMs & Containers')).toBeInTheDocument();
});
expect(screen.getByTestId('resource-name-guest2')).toHaveTextContent('redacted by policy');
expect(screen.getByTestId('resource-name-guest2')).not.toHaveTextContent('secret-vm-2');
expect(screen.getByTestId('resource-name-guest2')).toHaveTextContent('Secret VM 2');
expect(screen.getByTestId('resource-name-guest2')).not.toHaveTextContent('redacted by policy');
});
it('renders governed guest groups with the policy-aware node header label', async () => {
it('renders policy-redacted guest-group node headers with the raw display name', async () => {
setPathname('/alerts/thresholds/infrastructure');
const node = {
id: 'node-governed',
@ -489,11 +493,15 @@ describe('ThresholdsTable Resource Rendering', () => {
expect(screen.getByTestId('group-header-0')).toBeInTheDocument();
});
expect(screen.getByTestId('group-header-0')).toHaveTextContent('redacted by policy');
expect(screen.getByTestId('group-header-0')).not.toHaveTextContent('secret-node');
// Group headers run the same friendly-node normalizer as non-policied nodes
// (see "PVE Node 1" -> "PVE" earlier), so "Secret Node" friendly-shortens to
// "Secret". The point of this test is that the redacted aiSafeSummary does
// NOT replace the node header in operator-local UI.
expect(screen.getByTestId('group-header-0')).toHaveTextContent('Secret');
expect(screen.getByTestId('group-header-0')).not.toHaveTextContent('redacted by policy');
});
it('renders governed storage with the policy-aware display label', async () => {
it('renders policy-redacted storage with its raw display name in operator-local UI', async () => {
setPathname('/alerts/thresholds/infrastructure');
const storage = {
id: 'storage1',
@ -514,11 +522,11 @@ describe('ThresholdsTable Resource Rendering', () => {
expect(screen.getByTestId('resource-name-storage1')).toBeInTheDocument();
});
expect(screen.getByTestId('resource-name-storage1')).toHaveTextContent('redacted by policy');
expect(screen.getByTestId('resource-name-storage1')).not.toHaveTextContent('secret-datastore');
expect(screen.getByTestId('resource-name-storage1')).toHaveTextContent('Secret Datastore');
expect(screen.getByTestId('resource-name-storage1')).not.toHaveTextContent('redacted by policy');
});
it('renders governed docker containers with the policy-aware display label', async () => {
it('renders policy-redacted docker containers with their raw display name in operator-local UI', async () => {
setPathname('/alerts/thresholds/containers');
const dockerHost = {
id: 'docker-host-1',
@ -564,10 +572,10 @@ describe('ThresholdsTable Resource Rendering', () => {
expect(
screen.getByTestId('resource-name-docker:docker-host-1/container-governed'),
).toHaveTextContent('redacted by policy');
).toHaveTextContent('Secret Nginx');
expect(
screen.getByTestId('resource-name-docker:docker-host-1/container-governed'),
).not.toHaveTextContent('secret-nginx');
).not.toHaveTextContent('redacted by policy');
});
it('renders TrueNAS app containers under canonical container runtimes without Docker-only controls', async () => {
@ -616,7 +624,7 @@ describe('ThresholdsTable Resource Rendering', () => {
expect(screen.queryByText('Swarm service alerts')).not.toBeInTheDocument();
});
it('renders governed agent disk node labels with the policy-aware display label', async () => {
it('renders policy-redacted agent disk node labels with the raw display name in operator-local UI', async () => {
setPathname('/alerts/thresholds/systems');
const host = {
id: 'agent-governed',
@ -645,11 +653,11 @@ describe('ThresholdsTable Resource Rendering', () => {
});
expect(screen.getByTestId('resource-node-agent:agent-governed/disk:var-lib')).toHaveTextContent(
'redacted by policy',
'Secret Host',
);
expect(
screen.getByTestId('resource-node-agent:agent-governed/disk:var-lib'),
).not.toHaveTextContent('secret-host');
).not.toHaveTextContent('redacted by policy');
});
});

View file

@ -2,7 +2,7 @@ import type { EmailConfig } from '@/api/notifications';
import type { Resource, ResourceType } from '@/types/resource';
import type { RawOverrideConfig, HysteresisThreshold } from '@/types/alerts';
import { getResourceTypeLabel } from '@/utils/resourceTypePresentation';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
import {
MAX_ALERTS_MIN,
MAX_ALERTS_MAX,
@ -336,7 +336,12 @@ export const guessNumericId = (value: string): number => {
export const getAlertResourceDisplayLabel = (resource: Resource, fallback?: string): string =>
(() => {
const preferred = getPreferredResourceDisplayName(resource);
// Alert threshold tables, override pickers, and incident panels are operator-facing
// local UI. Per docs/PRIVACY.md, resource-policy redaction applies before non-local
// model requests leave the instance — not to the operator's own browser. Use the
// raw infrastructure display name so a Tower-policied row reads "Tower" here, the
// same as on /infrastructure and /storage.
const preferred = getPreferredInfrastructureDisplayName(resource);
if (preferred && preferred !== resource.id) {
return preferred;
}

View file

@ -32,7 +32,7 @@ export function useThresholdsDockerData(inputs: ThresholdsDataInputs) {
const hosts: TableResource[] = (props.containerRuntimes ?? []).map((host) => {
const idCandidates = dockerHostOverrideIdCandidates(host);
const originalName = getAlertResourceDisplayLabel(host);
const friendlyName = getFriendlyAlertNodeName(originalName, host.policy);
const friendlyName = getFriendlyAlertNodeName(originalName);
const override = findOverrideByCandidates(overridesMap, idCandidates);
const resourceId = override?.id || idCandidates[0] || host.id;
const disableConnectivity = override?.disableConnectivity || false;
@ -113,7 +113,7 @@ export function useThresholdsDockerData(inputs: ThresholdsDataInputs) {
const dockerHostIds = dockerHostOverrideIdCandidates(host);
const dockerHostIdForActions = dockerHostIds[0] || host.id;
const hostLabel = getAlertResourceDisplayLabel(host);
const friendlyHostName = getFriendlyAlertNodeName(hostLabel, host.policy);
const friendlyHostName = getFriendlyAlertNodeName(hostLabel);
const hostLabelLower = hostLabel.toLowerCase();
const friendlyHostNameLower = friendlyHostName.toLowerCase();
const hostHostname = getPreferredResourceHostname(host);
@ -230,7 +230,7 @@ export function useThresholdsDockerData(inputs: ThresholdsDataInputs) {
const meta: Record<string, GroupHeaderMeta> = {};
(props.containerRuntimes ?? []).forEach((host) => {
const originalName = getAlertResourceDisplayLabel(host);
const friendlyName = getFriendlyAlertNodeName(originalName, host.policy);
const friendlyName = getFriendlyAlertNodeName(originalName);
const headerMeta: GroupHeaderMeta = {
displayName: friendlyName,
rawName: originalName,

View file

@ -43,7 +43,7 @@ export function useThresholdsHostData(inputs: ThresholdsDataInputs) {
const hasNote = Boolean(note && note.trim().length > 0);
const originalDisplayName = getAlertResourceDisplayLabel(node);
const friendlyName = getFriendlyAlertNodeName(originalDisplayName, node.policy, clusterName);
const friendlyName = getFriendlyAlertNodeName(originalDisplayName, clusterName);
const rawName = node.name;
const sanitizedName = friendlyName || originalDisplayName || rawName.split('.')[0] || rawName;
const guestUrlValue = typeof data?.guestURL === 'string' ? data.guestURL.trim() : '';

View file

@ -1,7 +1,6 @@
import { unwrap } from 'solid-js/store';
import { getPreferredResourceHostname } from '@/utils/resourceIdentity';
import { requiresGovernedResourceDisplay } from '@/types/resource';
import type { Resource } from '@/types/resource';
import {
getAgentDiscoveryResourceId,
@ -149,9 +148,8 @@ export const getFriendlyNodeName = (value: string, clusterName?: string): string
export const getFriendlyAlertNodeName = (
value: string,
policy?: Resource['policy'],
clusterName?: string,
): string => (requiresGovernedResourceDisplay(policy) ? value : getFriendlyNodeName(value, clusterName));
): string => getFriendlyNodeName(value, clusterName);
export function buildNodeHeaderMeta(node: Resource) {
const data = platformData(node);
@ -160,7 +158,7 @@ export function buildNodeHeaderMeta(node: Resource) {
(data?.isClusterMember as boolean | undefined) ?? Boolean(node.clusterId);
const originalDisplayName = getAlertResourceDisplayLabel(node);
const friendlyName = getFriendlyAlertNodeName(originalDisplayName, node.policy, clusterName);
const friendlyName = getFriendlyAlertNodeName(originalDisplayName, clusterName);
const guestUrlValue = typeof data?.guestURL === 'string' ? data.guestURL.trim() : '';
const hostValue = typeof data?.host === 'string' ? data.host.trim() : '';

View file

@ -173,7 +173,12 @@ describe('normalizeMetricDelayMap', () => {
});
describe('alert resource display labels', () => {
it('uses the governed aiSafeSummary when policy requires redaction', () => {
it('returns the raw display name in operator-facing alert UI even when policy requires redaction', () => {
// Alert threshold tables, override pickers, and incident panels are local UI for
// the operator. Resource-policy redaction is a transmission-boundary policy
// (docs/PRIVACY.md): it gates non-local model requests, not the operator's own
// browser. The same row that reads "Secret Host" on /infrastructure must read
// "Secret Host" here, not "redacted by policy".
const resource = {
id: 'resource-1',
name: 'secret-host',
@ -189,7 +194,7 @@ describe('alert resource display labels', () => {
aiSafeSummary: 'redacted by policy',
} as unknown as Resource;
expect(getAlertResourceDisplayLabel(resource)).toBe('redacted by policy');
expect(getAlertResourceDisplayLabel(resource)).toBe('Secret Host');
});
it('falls back to the provided alert-specific fallback when needed', () => {