From 9dbf34366288592377f1e1bcbdb38b13425dc995 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 19 Mar 2026 09:39:25 +0000 Subject: [PATCH] Centralize trimmed string normalization --- .../components/Docker/SwarmServicesDrawer.tsx | 51 +++++++++---------- .../Kubernetes/K8sDeploymentsDrawer.tsx | 32 ++++++------ .../Kubernetes/K8sNamespacesDrawer.tsx | 9 ++-- .../src/components/PMG/PMGInstanceDrawer.tsx | 13 +++-- .../frontendResourceTypeBoundaries.test.ts | 12 ++++- .../utils/__tests__/textPresentation.test.ts | 6 --- .../utils/resourceCorrelationPresentation.ts | 14 ++--- frontend-modern/src/utils/textPresentation.ts | 12 ----- 8 files changed, 69 insertions(+), 80 deletions(-) diff --git a/frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx b/frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx index 823523f08..894e51616 100644 --- a/frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx +++ b/frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx @@ -14,6 +14,7 @@ import { } from '@/components/shared/Table'; import { EmptyState } from '@/components/shared/EmptyState'; import { getSimpleStatusIndicator } from '@/utils/status'; +import { asTrimmedString } from '@/utils/stringUtils'; import { formatSwarmClusterId, formatSwarmClusterSummary, @@ -77,8 +78,6 @@ type ResourcesListResponse = { }; }; -const normalize = (value?: string | null) => (value || '').trim(); - const buildServicesUrl = (cluster: string, page: number) => { const params = new URLSearchParams(); params.set('type', 'docker-service'); @@ -120,8 +119,8 @@ const fetchAllServices = async (cluster: string): Promise { - const state = normalize(update?.state); - const message = normalize(update?.message); + const state = asTrimmedString(update?.state) ?? ''; + const message = asTrimmedString(update?.message) ?? ''; if (!state && !message) return '—'; if (state && message) return `${state}: ${message}`; return state || message; @@ -133,7 +132,7 @@ const formatPorts = (ports?: DockerServicePort[]) => { .map((p) => { const target = typeof p.targetPort === 'number' ? String(p.targetPort) : ''; const published = typeof p.publishedPort === 'number' ? String(p.publishedPort) : ''; - const proto = normalize(p.protocol) || 'tcp'; + const proto = asTrimmedString(p.protocol) || 'tcp'; if (published && target) return `${published}->${target}/${proto}`; if (target) return `${target}/${proto}`; return ''; @@ -146,7 +145,7 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo const [search, setSearch] = createSignal(''); const drawerPresentation = getSwarmDrawerPresentation(); - const clusterKey = createMemo(() => normalize(props.cluster)); + const clusterKey = createMemo(() => asTrimmedString(props.cluster) ?? ''); const [services] = createResource( clusterKey, @@ -158,18 +157,18 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo ); const filteredServices = createMemo(() => { - const term = normalize(search()).toLowerCase(); + const term = (asTrimmedString(search()) ?? '').toLowerCase(); return services() .filter((svc) => { if (!term) return true; - const name = normalize(svc.name) || svc.id; - const stack = normalize(svc.docker?.stack); - const image = normalize(svc.docker?.image); + const name = asTrimmedString(svc.name) || svc.id; + const stack = asTrimmedString(svc.docker?.stack) ?? ''; + const image = asTrimmedString(svc.docker?.image) ?? ''; return [name, stack, image].some((value) => value.toLowerCase().includes(term)); }) .sort((a, b) => { - const aName = (normalize(a.name) || a.id).toLowerCase(); - const bName = (normalize(b.name) || b.id).toLowerCase(); + const aName = (asTrimmedString(a.name) || a.id).toLowerCase(); + const bName = (asTrimmedString(b.name) || b.id).toLowerCase(); if (aName !== bName) return aName < bName ? -1 : 1; return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }); @@ -177,9 +176,9 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo const swarm = createMemo(() => props.swarm); const clusterName = createMemo( - () => normalize(swarm()?.clusterName) || normalize(swarm()?.clusterId) || clusterKey(), + () => asTrimmedString(swarm()?.clusterName) || asTrimmedString(swarm()?.clusterId) || clusterKey(), ); - const clusterId = createMemo(() => normalize(swarm()?.clusterId)); + const clusterId = createMemo(() => asTrimmedString(swarm()?.clusterId) ?? ''); return (
@@ -211,20 +210,20 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo
- + - {formatSwarmRoleLabel(normalize(swarm()?.nodeRole))} + {formatSwarmRoleLabel(asTrimmedString(swarm()?.nodeRole))} - + - {formatSwarmStateLabel(normalize(swarm()?.localState))} + {formatSwarmStateLabel(asTrimmedString(swarm()?.localState))} @@ -235,9 +234,9 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo
- +
- {normalize(swarm()?.error)} + {asTrimmedString(swarm()?.error)}
@@ -287,10 +286,10 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo {(svc) => { - const name = () => normalize(svc.name) || svc.id; - const stack = () => normalize(svc.docker?.stack) || '—'; - const image = () => normalize(svc.docker?.image) || '—'; - const mode = () => normalize(svc.docker?.mode) || '—'; + const name = () => asTrimmedString(svc.name) || svc.id; + const stack = () => asTrimmedString(svc.docker?.stack) || '—'; + const image = () => asTrimmedString(svc.docker?.image) || '—'; + const mode = () => asTrimmedString(svc.docker?.mode) || '—'; const desired = () => svc.docker?.desiredTasks ?? 0; const running = () => svc.docker?.runningTasks ?? 0; const update = () => formatUpdate(svc.docker?.serviceUpdate); diff --git a/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx b/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx index aedf48911..c3595fd10 100644 --- a/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx +++ b/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx @@ -22,6 +22,7 @@ import { getK8sDeploymentsLoadingState, } from '@/utils/k8sDeploymentPresentation'; import { getSimpleStatusIndicator } from '@/utils/status'; +import { asTrimmedString } from '@/utils/stringUtils'; const PAGE_LIMIT = 100; const MAX_PAGES = 20; @@ -46,8 +47,6 @@ type ResourcesListResponse = { }; }; -const normalize = (value?: string | null) => (value || '').trim(); - const buildDeploymentsUrl = (cluster: string, page: number) => { const params = new URLSearchParams(); params.set('type', 'k8s-deployment'); @@ -100,7 +99,7 @@ export const K8sDeploymentsDrawer: Component<{ const [lastAppliedNamespace, setLastAppliedNamespace] = createSignal(''); const drawerPresentation = getK8sDeploymentsDrawerPresentation(); - const clusterName = createMemo(() => normalize(props.cluster)); + const clusterName = createMemo(() => asTrimmedString(props.cluster) ?? ''); const [deployments] = createResource( clusterName, @@ -114,7 +113,7 @@ export const K8sDeploymentsDrawer: Component<{ const namespaceOptions = createMemo(() => { const set = new Set(); for (const dep of deployments()) { - const ns = normalize(dep.kubernetes?.namespace); + const ns = asTrimmedString(dep.kubernetes?.namespace) ?? ''; if (ns) set.add(ns); } return Array.from(set).sort((a, b) => a.localeCompare(b)); @@ -122,9 +121,10 @@ export const K8sDeploymentsDrawer: Component<{ createEffect(() => { // Allow other drawer tabs (e.g., Namespaces) to prefill the namespace filter. - const next = normalize(props.initialNamespace); + const next = asTrimmedString(props.initialNamespace) ?? ''; if (!next) return; - if (next.toLowerCase() === normalize(lastAppliedNamespace()).toLowerCase()) return; + if (next.toLowerCase() === (asTrimmedString(lastAppliedNamespace()) ?? '').toLowerCase()) + return; const exists = namespaceOptions().some((ns) => ns.toLowerCase() === next.toLowerCase()); if (exists) { setNamespace(next); @@ -133,7 +133,7 @@ export const K8sDeploymentsDrawer: Component<{ }); createEffect(() => { - const current = normalize(namespace()); + const current = asTrimmedString(namespace()) ?? ''; if (!current) return; const exists = namespaceOptions().some((ns) => ns.toLowerCase() === current.toLowerCase()); if (!exists) { @@ -142,19 +142,19 @@ export const K8sDeploymentsDrawer: Component<{ }); const filteredDeployments = createMemo(() => { - const ns = normalize(namespace()); - const term = normalize(search()).toLowerCase(); + const ns = asTrimmedString(namespace()) ?? ''; + const term = (asTrimmedString(search()) ?? '').toLowerCase(); return deployments() .filter((dep) => { - if (ns && normalize(dep.kubernetes?.namespace) !== ns) return false; + if (ns && (asTrimmedString(dep.kubernetes?.namespace) ?? '') !== ns) return false; if (!term) return true; - const name = normalize(dep.name) || dep.id; + const name = asTrimmedString(dep.name) || dep.id; return name.toLowerCase().includes(term); }) .sort((a, b) => { - const aName = (normalize(a.name) || a.id).toLowerCase(); - const bName = (normalize(b.name) || b.id).toLowerCase(); + const aName = (asTrimmedString(a.name) || a.id).toLowerCase(); + const bName = (asTrimmedString(b.name) || b.id).toLowerCase(); if (aName !== bName) return aName < bName ? -1 : 1; return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }); @@ -167,7 +167,7 @@ export const K8sDeploymentsDrawer: Component<{ buildWorkloadsPath({ type: 'pod', context: cluster, - namespace: normalize(ns) || null, + namespace: asTrimmedString(ns) || null, }), ); }; @@ -256,8 +256,8 @@ export const K8sDeploymentsDrawer: Component<{ {(dep) => { - const name = () => normalize(dep.name) || dep.id; - const ns = () => normalize(dep.kubernetes?.namespace) || '—'; + const name = () => asTrimmedString(dep.name) || dep.id; + const ns = () => asTrimmedString(dep.kubernetes?.namespace) || '—'; const desired = () => dep.kubernetes?.desiredReplicas ?? 0; const updated = () => dep.kubernetes?.updatedReplicas ?? 0; const ready = () => dep.kubernetes?.readyReplicas ?? 0; diff --git a/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx b/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx index d6ca574b0..b34804f2c 100644 --- a/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx +++ b/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx @@ -22,6 +22,7 @@ import { getK8sNamespacesLoadingState, } from '@/utils/k8sNamespacePresentation'; import { getNamespaceCountsIndicator, type NamespaceCounts } from '@/utils/k8sStatusPresentation'; +import { asTrimmedString } from '@/utils/stringUtils'; type NamespaceRow = { namespace: string; @@ -34,8 +35,6 @@ type NamespacesResponse = { data: NamespaceRow[]; }; -const normalize = (value?: string | null) => (value || '').trim(); - const formatInteger = (value?: number | null): string => { const n = Number(value ?? 0); if (!Number.isFinite(n)) return '0'; @@ -50,7 +49,7 @@ export const K8sNamespacesDrawer: Component<{ const [search, setSearch] = createSignal(''); const drawerPresentation = getK8sNamespacesDrawerPresentation(); - const clusterName = createMemo(() => normalize(props.cluster)); + const clusterName = createMemo(() => asTrimmedString(props.cluster) ?? ''); const [namespaces] = createResource( clusterName, @@ -75,7 +74,7 @@ export const K8sNamespacesDrawer: Component<{ const rows = createMemo(() => (Array.isArray(namespaces()?.data) ? namespaces()!.data : [])); const filteredRows = createMemo(() => { - const term = normalize(search()).toLowerCase(); + const term = (asTrimmedString(search()) ?? '').toLowerCase(); if (!term) return rows(); return rows().filter((row) => row.namespace.toLowerCase().includes(term)); }); @@ -87,7 +86,7 @@ export const K8sNamespacesDrawer: Component<{ buildWorkloadsPath({ type: 'pod', context: cluster, - namespace: namespace ? normalize(namespace) : null, + namespace: asTrimmedString(namespace) ?? null, }), ); }; diff --git a/frontend-modern/src/components/PMG/PMGInstanceDrawer.tsx b/frontend-modern/src/components/PMG/PMGInstanceDrawer.tsx index 3614446e3..1ed7727a9 100644 --- a/frontend-modern/src/components/PMG/PMGInstanceDrawer.tsx +++ b/frontend-modern/src/components/PMG/PMGInstanceDrawer.tsx @@ -14,6 +14,7 @@ import { import { EmptyState } from '@/components/shared/EmptyState'; import { formatBytes, formatRelativeTime } from '@/utils/format'; import { getServiceHealthPresentation } from '@/utils/serviceHealthPresentation'; +import { asTrimmedString } from '@/utils/stringUtils'; import { getPMGDetailsDrawerPresentation, PMG_DETAILS_FAILURE_STATE_TITLE, @@ -121,8 +122,6 @@ type PMGInstanceDrawerProps = { resourceName?: string; }; -const normalize = (value?: string | null) => (value || '').trim(); - const formatCompact = (value?: number | null): string => { if (value === undefined || value === null || Number.isNaN(value)) return '—'; if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; @@ -135,7 +134,7 @@ export const PMGInstanceDrawer: Component = (props) => { const [searchDomain, setSearchDomain] = createSignal(''); const drawerPresentation = getPMGDetailsDrawerPresentation(); - const resourceId = createMemo(() => normalize(props.resourceId)); + const resourceId = createMemo(() => asTrimmedString(props.resourceId) ?? ''); const [resource] = createResource( resourceId, @@ -174,7 +173,7 @@ export const PMGInstanceDrawer: Component = (props) => { const relayDomains = createMemo(() => { const rows = pmg()?.relayDomains ?? []; - const term = normalize(searchRelay()).toLowerCase(); + const term = (asTrimmedString(searchRelay()) ?? '').toLowerCase(); if (!term) return rows; return rows.filter( (row) => @@ -184,7 +183,7 @@ export const PMGInstanceDrawer: Component = (props) => { const domainStats = createMemo(() => { const rows = pmg()?.domainStats ?? []; - const term = normalize(searchDomain()).toLowerCase(); + const term = (asTrimmedString(searchDomain()) ?? '').toLowerCase(); const filtered = term ? rows.filter((row) => row.domain.toLowerCase().includes(term)) : rows; return [...filtered].sort((a, b) => (b.mailCount || 0) - (a.mailCount || 0)); }); @@ -192,7 +191,7 @@ export const PMGInstanceDrawer: Component = (props) => { const spamBuckets = createMemo(() => { const rows = pmg()?.spamDistribution ?? []; const parsed = rows - .map((row) => ({ bucket: normalize(row.bucket), count: Number(row.count || 0) })) + .map((row) => ({ bucket: asTrimmedString(row.bucket) ?? '', count: Number(row.count || 0) })) .filter((row) => row.bucket.length > 0); return parsed.sort((a, b) => a.bucket.localeCompare(b.bucket)); }); @@ -240,7 +239,7 @@ export const PMGInstanceDrawer: Component = (props) => {
- {normalize(props.resourceName) || + {asTrimmedString(props.resourceName) || resource()?.name || drawerPresentation.defaultResourceName}
diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 7fbd09fb7..661f94424 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -1917,7 +1917,7 @@ describe('frontend resource type boundaries', () => { expect(resourceRelationshipPresentationSource).toContain('formatConfidencePercentage'); expect(resourceCorrelationPresentationSource).toContain('formatConfidencePercentage'); expect(resourceCorrelationPresentationSource).toContain('humanizeArrowDelimitedLabel'); - expect(resourceCorrelationPresentationSource).toContain('formatTrimmedLabel'); + expect(resourceCorrelationPresentationSource).toContain('asTrimmedString'); expect(confidencePresentationSource).toContain('formatConfidencePercentage'); expect(confidencePresentationSource).toContain('formatConfidenceLabel'); expect(approvalPresentationSource).toContain('getResourceApprovalLevelLabel'); @@ -1926,7 +1926,6 @@ describe('frontend resource type boundaries', () => { expect(resourceChangePresentationSource).toContain('humanizeToken'); expect(resourceRelationshipPresentationSource).toContain('humanizeToken'); expect(textPresentationSource).toContain('humanizeArrowDelimitedLabel'); - expect(textPresentationSource).toContain('formatTrimmedLabel'); expect(resourceCorrelationPresentationSource).not.toContain('formatResourceCorrelationEndpointLabel'); expect(resourceCorrelationPresentationSource).not.toContain( "replace(/\\s*->\\s*/g, ' → ')", @@ -1934,6 +1933,15 @@ describe('frontend resource type boundaries', () => { expect(resourceDetailDrawerSource).toContain('humanizeToken'); expect(textPresentationSource).toContain('humanizeToken'); expect(textPresentationSource).toContain('formatIdentifierLabel'); + expect(resourceCorrelationPresentationSource).not.toContain('formatTrimmedLabel'); + expect(swarmServicesDrawerSource).toContain('asTrimmedString'); + expect(swarmServicesDrawerSource).not.toContain("const normalize = (value?: string | null) => (value || '').trim();"); + expect(pmgInstanceDrawerSource).toContain('asTrimmedString'); + expect(pmgInstanceDrawerSource).not.toContain("const normalize = (value?: string | null) => (value || '').trim();"); + expect(k8sNamespacesDrawerSource).toContain('asTrimmedString'); + expect(k8sNamespacesDrawerSource).not.toContain("const normalize = (value?: string | null) => (value || '').trim();"); + expect(k8sDeploymentsDrawerSource).toContain('asTrimmedString'); + expect(k8sDeploymentsDrawerSource).not.toContain("const normalize = (value?: string | null) => (value || '').trim();"); expect(messageItemSource).toContain('formatIdentifierLabel'); expect(toolExecutionBlockSource).toContain('formatIdentifierLabel'); expect(aiChatSource).toContain('formatIdentifierLabel'); diff --git a/frontend-modern/src/utils/__tests__/textPresentation.test.ts b/frontend-modern/src/utils/__tests__/textPresentation.test.ts index 11cf71bfc..df255f237 100644 --- a/frontend-modern/src/utils/__tests__/textPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/textPresentation.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import { formatIdentifierLabel, - formatTrimmedLabel, humanizeArrowDelimitedLabel, humanizeToken, titleCaseDelimitedLabel, @@ -32,11 +31,6 @@ describe('textPresentation', () => { expect(formatIdentifierLabel(undefined, { fallback: 'Unknown' })).toBe('Unknown'); }); - it('formats trimmed labels with a fallback', () => { - expect(formatTrimmedLabel(' endpoint ', { fallback: 'Unknown resource' })).toBe('endpoint'); - expect(formatTrimmedLabel('', { fallback: 'Unknown resource' })).toBe('Unknown resource'); - }); - it('title-cases delimited labels with configurable separators', () => { expect(titleCaseDelimitedLabel('docker_host')).toBe('Docker Host'); expect(titleCaseDelimitedLabel('IP_address', { preserveShortAllCaps: true })).toBe( diff --git a/frontend-modern/src/utils/resourceCorrelationPresentation.ts b/frontend-modern/src/utils/resourceCorrelationPresentation.ts index a9e28b612..1d7f381a1 100644 --- a/frontend-modern/src/utils/resourceCorrelationPresentation.ts +++ b/frontend-modern/src/utils/resourceCorrelationPresentation.ts @@ -1,7 +1,8 @@ import type { ResourceCorrelation } from '@/types/aiIntelligence'; import { formatDurationMs } from '@/utils/patrolFormat'; import { formatConfidencePercentage } from '@/utils/confidencePresentation'; -import { formatTrimmedLabel, humanizeArrowDelimitedLabel } from '@/utils/textPresentation'; +import { asTrimmedString } from '@/utils/stringUtils'; +import { humanizeArrowDelimitedLabel } from '@/utils/textPresentation'; const parseGoDurationMs = (value: string): number | null => { const normalized = value.trim(); @@ -35,11 +36,12 @@ export function formatResourceCorrelationEndpoint( correlation: ResourceCorrelation, role: 'source' | 'target', ): string { - return formatTrimmedLabel( - role === 'source' - ? correlation.source_name || correlation.source_id - : correlation.target_name || correlation.target_id, - { fallback: 'Unknown resource' }, + return ( + asTrimmedString( + role === 'source' + ? correlation.source_name || correlation.source_id + : correlation.target_name || correlation.target_id, + ) || 'Unknown resource' ); } diff --git a/frontend-modern/src/utils/textPresentation.ts b/frontend-modern/src/utils/textPresentation.ts index 835e2a8d9..76141e4ce 100644 --- a/frontend-modern/src/utils/textPresentation.ts +++ b/frontend-modern/src/utils/textPresentation.ts @@ -15,10 +15,6 @@ export interface TitleCaseLabelOptions { separators?: RegExp; } -export interface TrimmedLabelOptions { - fallback?: string; -} - export function humanizeToken(value?: string, options?: HumanizeTokenOptions): string { const normalized = (value || '').trim(); if (!normalized) { @@ -87,14 +83,6 @@ export function titleCaseDelimitedLabel( .join(' '); } -export function formatTrimmedLabel( - value?: string, - options?: TrimmedLabelOptions, -): string { - const normalized = (value || '').trim(); - return normalized || options?.fallback || ''; -} - export function humanizeArrowDelimitedLabel( value?: string, options?: HumanizeTokenOptions,