mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
Remove exports orphaned by the unified resource table removal
PR 2612 deleted the never-mounted UnifiedResourceTable, which left
exports whose only production callers were the deleted files. An import
graph from src/index.tsx confirms none of them is reachable now.
infrastructureSelectors.ts served one live caller: workloadSelectors used
computeIOScale for Net I/O and Disk I/O outlier emphasis, by building
fake Resource objects from guests and casting them. The median, MAD and
percentile math now lives in workloadSelectors and reads guest throughput
directly, so the file and its search, sort, grouping and summary-scope
helpers go. Output is unchanged for every input, including null,
negative and non-finite throughput.
getResourcePolicyTableBadges, hasBlockingResourcePolicyPosture and
shouldShowResourceAlternateName only fed the deleted table cards, and
ResourceFacetSummary's maxVisibleBadges and class props (with the +N
overflow chip) were only passed by the deleted rows. The detail drawer,
the component's one live caller, still renders every chip.
Registry ownership, proof entries and lane L10 evidence drop the deleted
file. Contract prose that described the selector pipeline or table-only
behaviour as live is removed or made conditional. Completion Obligation
15 now states the operator-local raw-name search rule that a17f879a19
introduced, instead of the governed-label search it replaced.
A production build keeps the same chunk set; WorkloadsSurface and the
shared MetricBar chunk shrink and the other chunks change only in
minified names.
This commit is contained in:
@@ -5661,11 +5661,6 @@
|
|||||||
"performance-and-scalability"
|
"performance-and-scalability"
|
||||||
],
|
],
|
||||||
"evidence": [
|
"evidence": [
|
||||||
{
|
|
||||||
"repo": "pulse",
|
|
||||||
"path": "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
|
|
||||||
"kind": "file"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"repo": "pulse",
|
"repo": "pulse",
|
||||||
"path": "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
|
"path": "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
|
||||||
|
|||||||
@@ -488,7 +488,6 @@ in-place click and chevron activation over a selection.
|
|||||||
64. `frontend-modern/src/components/Workloads/__tests__/workloadRouteStateModel.test.ts`
|
64. `frontend-modern/src/components/Workloads/__tests__/workloadRouteStateModel.test.ts`
|
||||||
65. `frontend-modern/src/components/Workloads/__tests__/workloadUrlSyncModel.test.ts`
|
65. `frontend-modern/src/components/Workloads/__tests__/workloadUrlSyncModel.test.ts`
|
||||||
66. `frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts`
|
66. `frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts`
|
||||||
76. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts`
|
|
||||||
77. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts`
|
77. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts`
|
||||||
78. `frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx`
|
78. `frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx`
|
||||||
79. `frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx`
|
79. `frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx`
|
||||||
@@ -550,7 +549,6 @@ at desktop and phone widths; this is presentation proof, not native collection
|
|||||||
or a measured fleet performance improvement.
|
or a measured fleet performance improvement.
|
||||||
|
|
||||||
|
|
||||||
1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `unified-resources`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
|
||||||
2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||||
10. `frontend-modern/src/components/Infrastructure/useTableWindowing.ts` shared with `frontend-primitives`: the shared bounded table-window controller is both a canonical frontend rendering primitive and a fleet-scale scrolling hot-path boundary.
|
10. `frontend-modern/src/components/Infrastructure/useTableWindowing.ts` shared with `frontend-primitives`: the shared bounded table-window controller is both a canonical frontend rendering primitive and a fleet-scale scrolling hot-path boundary.
|
||||||
12. `frontend-modern/src/features/platformPage/PlatformWindowedList.tsx` shared with `frontend-primitives`: the shared bounded list renderer is both a canonical platform-page primitive and a fleet-scale mounted-DOM performance boundary.
|
12. `frontend-modern/src/features/platformPage/PlatformWindowedList.tsx` shared with `frontend-primitives`: the shared bounded list renderer is both a canonical platform-page primitive and a fleet-scale mounted-DOM performance boundary.
|
||||||
@@ -1066,6 +1064,11 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
|||||||
scope must be applied before deriving host, Kubernetes context/namespace,
|
scope must be applied before deriving host, Kubernetes context/namespace,
|
||||||
and container runtime facet options so embedded pages do not pay for or
|
and container runtime facet options so embedded pages do not pay for or
|
||||||
display unrelated platform options.
|
display unrelated platform options.
|
||||||
|
Net I/O and Disk I/O outlier statistics (median, MAD, p97, p99) are built
|
||||||
|
from guest throughput by `computeWorkloadIOEmphasis(...)` in
|
||||||
|
`workloadSelectors.ts`, which produces the stats that
|
||||||
|
`getOutlierEmphasis(...)` in `guestRowModel.tsx` reads; that math must not
|
||||||
|
move back behind a `Resource`-shaped infrastructure selector cast.
|
||||||
Workloads source-health messaging must derive from the viewer-safe
|
Workloads source-health messaging must derive from the viewer-safe
|
||||||
`/api/runtime/inventory-sources` projection through
|
`/api/runtime/inventory-sources` projection through
|
||||||
`frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts`.
|
`frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts`.
|
||||||
@@ -2943,12 +2946,12 @@ history into equal-time summary buckets before it reaches the shell/runtime
|
|||||||
owners, so long-range cards do not bunch recent higher-resolution samples at
|
owners, so long-range cards do not bunch recent higher-resolution samples at
|
||||||
the right edge.
|
the right edge.
|
||||||
Compact resource-facet summary chips render through the shared
|
Compact resource-facet summary chips render through the shared
|
||||||
`ResourceFacetSummary` component, whose production consumer is the detail
|
`ResourceFacetSummary` component, whose only production consumer is the
|
||||||
drawer's change history. A table-row use must set an explicit visible chip
|
detail drawer's change history, so it renders every chip. A table-row use must
|
||||||
limit with overflow disclosure instead of letting mock-rich rows wrap an
|
first give that shared component a visible chip limit with overflow disclosure
|
||||||
unbounded badge list, and must stay within the table's bounded windowing and
|
instead of letting mock-rich rows wrap an unbounded badge list, and must stay
|
||||||
mounted-row budget rather than forking separate table-only presentation
|
within the table's bounded windowing and mounted-row budget rather than forking
|
||||||
logic. That component now also
|
separate table-only presentation logic. That component now also
|
||||||
consumes the shared `frontend-modern/src/utils/resourceChangePresentation.ts`
|
consumes the shared `frontend-modern/src/utils/resourceChangePresentation.ts`
|
||||||
label helper for canonical change kinds, source types, and adapter provenance
|
label helper for canonical change kinds, source types, and adapter provenance
|
||||||
so the chip wording stays consistent without adding extra hot-path branching.
|
so the chip wording stays consistent without adding extra hot-path branching.
|
||||||
@@ -2975,31 +2978,21 @@ history overview down to timeline counts and timeline-summary chips, so the
|
|||||||
performance-sensitive shared presentation path stays aligned with the
|
performance-sensitive shared presentation path stays aligned with the
|
||||||
investigation-first product contract instead of rendering low-signal generic
|
investigation-first product contract instead of rendering low-signal generic
|
||||||
facet sections by default.
|
facet sections by default.
|
||||||
Governance metadata such as sensitivity and routing scope may be visible in
|
Governance metadata such as sensitivity and routing scope shown in a table
|
||||||
the table, but it must remain on the same bounded row-windowing and mounted-row
|
must remain on the same bounded row-windowing and mounted-row budget rather
|
||||||
budget rather than creating a separate unbounded rendering path for
|
than creating a separate unbounded rendering path for policy-rich fleets.
|
||||||
policy-rich fleets.
|
The detail drawer's governance summary resolves governed resources through the
|
||||||
The detail drawer now also renders governed resource labels through the
|
canonical policy-aware `getResourcePolicyGovernedSummary(...)` helper. That
|
||||||
shared identity/display contract, which routes policy-aware resources through
|
keeps the policy-aware label path inside the same rendering budget instead of
|
||||||
the canonical policy-aware helper and suppresses the raw alternate name when
|
adding a second display branch for redacted fleets.
|
||||||
policy requires governed handling. That keeps the policy-aware label path
|
|
||||||
inside the same rendering budget instead of adding a second display branch for
|
|
||||||
redacted fleets.
|
|
||||||
Platform tables pass the canonical resource-label resolver into the detail
|
Platform tables pass the canonical resource-label resolver into the detail
|
||||||
drawer through `PlatformResourceDetailTableRow` so related-resource chips in
|
drawer through `PlatformResourceDetailTableRow` so related-resource chips in
|
||||||
the timeline/history path can resolve through the canonical catalog without adding a separate
|
the timeline/history path can resolve through the canonical catalog without adding a separate
|
||||||
detail-only lookup branch to the hot-row path.
|
detail-only lookup branch to the hot-row path.
|
||||||
The same detail drawer also uses that resolver for correlation dependency and
|
The same detail drawer also uses that resolver for correlation dependency and
|
||||||
dependent chips, so the investigation path does not fall back to raw IDs in
|
dependent chips, so the investigation path shows catalog labels in the drawer
|
||||||
the drawer while the AI page keeps its broader no-catalog fallback.
|
and keeps the raw ID only when the catalog cannot resolve one, while the AI
|
||||||
The shared infrastructure selector search path now also routes through that
|
page keeps its broader no-catalog fallback.
|
||||||
same preferred resource display contract, so governed resources do not
|
|
||||||
reappear via raw-name search candidates while the selector stays on the same
|
|
||||||
hot-path budget.
|
|
||||||
The shared workloads-link helper used by the resource drawer and table now
|
|
||||||
also routes its Kubernetes-cluster fallback through the same preferred
|
|
||||||
resource display contract, so navigation context does not leak raw
|
|
||||||
`displayName` values for governed clusters.
|
|
||||||
That same workloads-link path and the workload projection now also
|
That same workloads-link path and the workload projection now also
|
||||||
share the canonical cluster-name helpers in the shared agent-resource layer,
|
share the canonical cluster-name helpers in the shared agent-resource layer,
|
||||||
so route labels, pod grouping, and cluster-name fetch keys keep using the
|
so route labels, pod grouping, and cluster-name fetch keys keep using the
|
||||||
@@ -3061,17 +3054,6 @@ cards. Future throughput wording or workload-summary hot-path changes must
|
|||||||
extend these performance-owned surfaces instead of leaving the formatter or
|
extend these performance-owned surfaces instead of leaving the formatter or
|
||||||
summary shell unowned in registry coverage.
|
summary shell unowned in registry coverage.
|
||||||
|
|
||||||
Infrastructure selector status ordering must now tolerate arbitrary filter-set
|
|
||||||
strings without widening the canonical hot-path order tuple. Unknown statuses
|
|
||||||
must sort after the governed status order instead of forcing the selector path
|
|
||||||
to abandon the typed canonical order used by the infrastructure table and its
|
|
||||||
performance proof surface.
|
|
||||||
|
|
||||||
The Infrastructure page now also normalizes source filter keys through the
|
|
||||||
shared `frontend-modern/src/utils/sourcePlatforms.ts` helper directly, so the
|
|
||||||
selector boundary keeps using the canonical source-platform contract instead of
|
|
||||||
maintaining a local source-normalization alias.
|
|
||||||
|
|
||||||
Resource detail mappers now also use the shared
|
Resource detail mappers now also use the shared
|
||||||
`frontend-modern/src/utils/textPresentation.ts` title-case helper for sensor
|
`frontend-modern/src/utils/textPresentation.ts` title-case helper for sensor
|
||||||
labels, so the canonical presentation layer owns that wording instead of the
|
labels, so the canonical presentation layer owns that wording instead of the
|
||||||
|
|||||||
@@ -137,14 +137,6 @@
|
|||||||
"frontend-primitives"
|
"frontend-primitives"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
|
|
||||||
"rationale": "the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary",
|
|
||||||
"subsystems": [
|
|
||||||
"performance-and-scalability",
|
|
||||||
"unified-resources"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
|
"path": "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
|
||||||
"rationale": "resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary",
|
"rationale": "resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary",
|
||||||
@@ -7043,7 +7035,6 @@
|
|||||||
"pkg/metrics/"
|
"pkg/metrics/"
|
||||||
],
|
],
|
||||||
"owned_files": [
|
"owned_files": [
|
||||||
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
|
|
||||||
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
|
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
|
||||||
"frontend-modern/src/components/Infrastructure/useTableWindowing.ts",
|
"frontend-modern/src/components/Infrastructure/useTableWindowing.ts",
|
||||||
"frontend-modern/src/components/Workloads/DiskList.tsx",
|
"frontend-modern/src/components/Workloads/DiskList.tsx",
|
||||||
@@ -7203,7 +7194,6 @@
|
|||||||
"label": "Workloads hot-path proof",
|
"label": "Workloads hot-path proof",
|
||||||
"match_prefixes": [],
|
"match_prefixes": [],
|
||||||
"match_files": [
|
"match_files": [
|
||||||
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
|
|
||||||
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
|
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
|
||||||
"frontend-modern/src/components/Infrastructure/useTableWindowing.ts",
|
"frontend-modern/src/components/Infrastructure/useTableWindowing.ts",
|
||||||
"frontend-modern/src/components/Workloads/DiskList.tsx",
|
"frontend-modern/src/components/Workloads/DiskList.tsx",
|
||||||
@@ -7274,7 +7264,6 @@
|
|||||||
"allow_same_subsystem_tests": false,
|
"allow_same_subsystem_tests": false,
|
||||||
"test_prefixes": [],
|
"test_prefixes": [],
|
||||||
"exact_files": [
|
"exact_files": [
|
||||||
"frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts",
|
|
||||||
"frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts",
|
"frontend-modern/src/components/Infrastructure/__tests__/resourceDetailMappers.test.ts",
|
||||||
"frontend-modern/src/components/Infrastructure/__tests__/useTableWindowing.test.ts",
|
"frontend-modern/src/components/Infrastructure/__tests__/useTableWindowing.test.ts",
|
||||||
"frontend-modern/src/components/Workloads/__tests__/AvailabilityProbeStatusCard.test.tsx",
|
"frontend-modern/src/components/Workloads/__tests__/AvailabilityProbeStatusCard.test.tsx",
|
||||||
@@ -8003,7 +7992,6 @@
|
|||||||
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
|
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
|
||||||
"frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx",
|
"frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx",
|
"frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
|
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
|
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx",
|
"frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx",
|
"frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx",
|
||||||
@@ -8202,7 +8190,6 @@
|
|||||||
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
|
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
|
||||||
"frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx",
|
"frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx",
|
"frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
|
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
|
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx",
|
"frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx",
|
"frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx",
|
||||||
@@ -8300,7 +8287,6 @@
|
|||||||
"frontend-modern/src/components/Discovery/__tests__/AvailabilityProposalCard.test.tsx",
|
"frontend-modern/src/components/Discovery/__tests__/AvailabilityProposalCard.test.tsx",
|
||||||
"frontend-modern/src/components/Discovery/__tests__/availabilityProposalModel.test.ts",
|
"frontend-modern/src/components/Discovery/__tests__/availabilityProposalModel.test.ts",
|
||||||
"frontend-modern/src/components/Discovery/__tests__/useDiscoveryFeatureAvailability.test.tsx",
|
"frontend-modern/src/components/Discovery/__tests__/useDiscoveryFeatureAvailability.test.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/__tests__/infrastructureSelectors.test.ts",
|
|
||||||
"frontend-modern/src/components/Infrastructure/__tests__/ResourceChangeSummary.test.tsx",
|
"frontend-modern/src/components/Infrastructure/__tests__/ResourceChangeSummary.test.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx",
|
"frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts",
|
"frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts",
|
||||||
|
|||||||
@@ -1723,7 +1723,6 @@ the attached container fields searchable and attention-filterable from the
|
|||||||
network detail disclosure instead of forcing operators to inspect a full
|
network detail disclosure instead of forcing operators to inspect a full
|
||||||
container inventory table.
|
container inventory table.
|
||||||
|
|
||||||
1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `performance-and-scalability`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
|
||||||
2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `performance-and-scalability`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `performance-and-scalability`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||||
12. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx` shared with `storage-recovery`: Proxmox backup server table rows are both a storage/recovery backup-health surface and a unified-resource platform-table consumer boundary.
|
12. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx` shared with `storage-recovery`: Proxmox backup server table rows are both a storage/recovery backup-health surface and a unified-resource platform-table consumer boundary.
|
||||||
The table is composed on the Proxmox Backups tab and must not be duplicated
|
The table is composed on the Proxmox Backups tab and must not be duplicated
|
||||||
@@ -2592,11 +2591,13 @@ application resource-provider or WebSocket lifecycle.
|
|||||||
identity, table membership, source scope, or summary-hover state. The
|
identity, table membership, source scope, or summary-hover state. The
|
||||||
retired top-level `/infrastructure` page and its saved-view/route-state
|
retired top-level `/infrastructure` page and its saved-view/route-state
|
||||||
machinery must not be reintroduced for this purpose.
|
machinery must not be reintroduced for this purpose.
|
||||||
15. Keep infrastructure search aligned with the governed display label. Shared
|
15. Keep operator-local resource search on the operator's own names.
|
||||||
infrastructure filtering through
|
Resource-policy redaction is a transmission boundary (`docs/PRIVACY.md`),
|
||||||
`frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts`
|
so local search over unified resources, such as the `ResourcePicker` and
|
||||||
must match the user-visible safe label for governed resources instead of
|
`useResources` search filters, matches the raw name from
|
||||||
reintroducing redacted hostnames through search-only fallback candidates.
|
`getPreferredInfrastructureDisplayName(...)` plus the resource ID rather
|
||||||
|
than the governed safe label. Provider-bound labels keep the governed
|
||||||
|
`getPreferredResourceDisplayName(...)` path.
|
||||||
16. Preserve provider-backed storage backing-pool identity on canonical
|
16. Preserve provider-backed storage backing-pool identity on canonical
|
||||||
storage resources. `internal/unifiedresources/types.go`,
|
storage resources. `internal/unifiedresources/types.go`,
|
||||||
`internal/unifiedresources/adapters.go`, `internal/unifiedresources/views.go`,
|
`internal/unifiedresources/adapters.go`, `internal/unifiedresources/views.go`,
|
||||||
@@ -4469,15 +4470,14 @@ The same policy presenter now also owns the routing-scope labels used across
|
|||||||
AI-facing policy surfaces, while the resource detail drawer stays on
|
AI-facing policy surfaces, while the resource detail drawer stays on
|
||||||
per-resource policy lines instead of reconstructing a separate
|
per-resource policy lines instead of reconstructing a separate
|
||||||
`Allowed`/`Blocked` row or `Cloud Summary` decision row locally.
|
`Allowed`/`Blocked` row or `Cloud Summary` decision row locally.
|
||||||
The infrastructure host-table shell now treats the default
|
The default `Internal` + `Cloud Summary` posture is canonical policy metadata
|
||||||
`Internal` + `Cloud Summary` posture as canonical policy metadata that should
|
that stays available in the drawer and AI/governance surfaces without being
|
||||||
stay available in the drawer and AI/governance surfaces without being promoted
|
promoted to always-on row chrome. A table that shows inline policy badges
|
||||||
to always-on row chrome. Inline row badges are reserved for blocking policy
|
reserves them for blocking policy states such as `local-only` routing or
|
||||||
states such as `local-only` routing or `restricted` sensitivity, while
|
`restricted` sensitivity, while `sensitive` + `local-first` and redaction-only
|
||||||
`sensitive` + `local-first` and redaction-only posture remains visible in Data
|
posture remains visible in Data Handling, detail, and AI/governance surfaces.
|
||||||
Handling, detail, and AI/governance surfaces. The table must not imply that
|
A table must not imply that ordinary sensitive resources carry an
|
||||||
ordinary sensitive resources carry an operator-actionable infrastructure
|
operator-actionable infrastructure exception.
|
||||||
exception.
|
|
||||||
The resource drawer now applies the same rule to its investigation-context
|
The resource drawer now applies the same rule to its investigation-context
|
||||||
governance block: the default posture remains part of the canonical policy
|
governance block: the default posture remains part of the canonical policy
|
||||||
contract, but the drawer only surfaces the governance section when the policy
|
contract, but the drawer only surfaces the governance section when the policy
|
||||||
@@ -5061,11 +5061,10 @@ The frontend unified-resource hook now trusts backend canonical `policy` and
|
|||||||
`aiSafeSummary` values directly, so the canonical summary value stays
|
`aiSafeSummary` values directly, so the canonical summary value stays
|
||||||
aligned with the same policy-aware contract that governs sensitivity and
|
aligned with the same policy-aware contract that governs sensitivity and
|
||||||
routing metadata without re-normalizing locally.
|
routing metadata without re-normalizing locally.
|
||||||
The resource detail drawer and unified resource table now also render that
|
The resource detail drawer's governance summary now also renders that
|
||||||
governed display label through the same policy-aware helper, and they suppress
|
governed label through the same policy-aware helper, so it stays aligned with
|
||||||
the raw alternate name when policy requires governed handling, so the visible
|
the backend redaction boundary instead of reconstructing a local name
|
||||||
label stays aligned with the backend redaction boundary instead of
|
fallback.
|
||||||
reconstructing a local name fallback.
|
|
||||||
The resource detail drawer now also resolves its AI-safe summary through that
|
The resource detail drawer now also resolves its AI-safe summary through that
|
||||||
same helper, so governed resources still present the canonical redacted label
|
same helper, so governed resources still present the canonical redacted label
|
||||||
when the backend summary is missing instead of dropping the governed summary
|
when the backend summary is missing instead of dropping the governed summary
|
||||||
@@ -5308,34 +5307,26 @@ path-policy coverage, so new unified-resource-owned runtime files must be added
|
|||||||
to a concrete proof route instead of falling back to subsystem-default
|
to a concrete proof route instead of falling back to subsystem-default
|
||||||
verification.
|
verification.
|
||||||
|
|
||||||
The infrastructure table, selector, and detail-mapper frontend consumers are
|
The detail-mapper frontend consumer is governed as an explicit shared
|
||||||
now governed as explicit shared boundaries with the performance lane rather
|
boundary with the performance lane rather than implicit downstream usage, so
|
||||||
than implicit downstream usage. That means future fleet-scale table changes
|
future fleet-scale changes there must preserve both canonical unified-resource
|
||||||
must preserve both canonical unified-resource semantics and the table
|
semantics and the performance proof route. The resource drawer surfaces
|
||||||
performance proof route. The shared resource table and resource drawer now
|
compact timeline summary chips through the shared `ResourceFacetSummary`, so
|
||||||
surface compact timeline summary chips, so facet presentation changes must
|
facet presentation changes must continue to flow through that component rather
|
||||||
continue to flow through the same governed resource-row surface rather than
|
than inventing a separate ad hoc summary path. A dense table row that adopts
|
||||||
inventing a separate ad hoc summary path. Dense table rows must bound those
|
those chips must first give the shared component a visible limit and overflow
|
||||||
chips with an explicit visible limit and overflow label, while row-level
|
label, and any row-level policy chip stays limited to blocking
|
||||||
policy chips are limited to blocking `local-only`/`restricted` posture so
|
`local-only`/`restricted` posture so mock-rich canonical resources cannot stack
|
||||||
mock-rich canonical resources cannot stack or overstate governance badges
|
or overstate governance badges inside the resource column. Those summaries now
|
||||||
inside the resource column. Those row summaries now prefer canonical
|
prefer canonical `facetCounts` on the
|
||||||
`facetCounts` on the
|
|
||||||
resource object when available, so the backend list/read shapes remain the
|
resource object when available, so the backend list/read shapes remain the
|
||||||
source of truth instead of forcing the frontend to infer totals only from
|
source of truth instead of forcing the frontend to infer totals only from
|
||||||
loaded slices. The drawer now fetches those facets
|
loaded slices. The drawer now fetches those facets
|
||||||
through one backend bundle endpoint, and that shared facet bundle preserves
|
through one backend bundle endpoint, and that shared facet bundle preserves
|
||||||
the timeline slice plus recent-change counts so the overview card and history
|
the timeline slice plus recent-change counts so the overview card and history
|
||||||
summary can report the loaded history instead of collapsing to the currently
|
summary can report the loaded history instead of collapsing to the currently
|
||||||
loaded page when the timeline endpoint is paginated. Timeline references in
|
loaded page when the timeline endpoint is paginated. Related resources on
|
||||||
that drawer now route
|
those timeline entries render through the drawer's resource-label resolver.
|
||||||
through the canonical infrastructure resource filter, so the resource history
|
|
||||||
remains navigable from the history surface instead of being purely
|
|
||||||
descriptive text.
|
|
||||||
The same infrastructure selector pipeline now also uses the policy-aware
|
|
||||||
display contract for search candidates, so governed resources do not reappear
|
|
||||||
through raw-name search forks even though the selector stays on the same
|
|
||||||
hot-path budget.
|
|
||||||
The shared recent-change presentation boundary is also owned here now.
|
The shared recent-change presentation boundary is also owned here now.
|
||||||
`frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
|
`frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
|
||||||
is the canonical shared card for recent resource-change timelines, while
|
is the canonical shared card for recent resource-change timelines, while
|
||||||
@@ -5660,14 +5651,8 @@ Unknown source types may contribute identity and source-label evidence, but
|
|||||||
they must not outrank the known canonical primary-type order when a merged host
|
they must not outrank the known canonical primary-type order when a merged host
|
||||||
contains a governed connector such as `proxmox-pve` or `agent`.
|
contains a governed connector such as `proxmox-pve` or `agent`.
|
||||||
|
|
||||||
Infrastructure selector consumers must also preserve the canonical
|
The canonical `KnownSourcePlatform` source-filter boundary applies to
|
||||||
`KnownSourcePlatform` normalization boundary when collecting source filters and
|
infrastructure source UI options in Settings and platform/runtime pages. Those surfaces may render
|
||||||
status facets. The selector layer may accept arbitrary user-visible filter
|
|
||||||
strings, but it must not widen the canonical unified-resource source/status
|
|
||||||
contracts that feed the infrastructure table and workload links.
|
|
||||||
|
|
||||||
The same source-filter boundary now also applies to infrastructure source UI
|
|
||||||
options in Settings and platform/runtime pages. Those surfaces may render
|
|
||||||
friendly string keys, but membership checks against available sources must
|
friendly string keys, but membership checks against available sources must
|
||||||
normalize through the shared `frontend-modern/src/utils/sourcePlatforms.ts`
|
normalize through the shared `frontend-modern/src/utils/sourcePlatforms.ts`
|
||||||
helper before consulting `KnownSourcePlatform` sets.
|
helper before consulting `KnownSourcePlatform` sets.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"base_sha": "a21e0ef8f819c404338661f76b82b5bea09aedfd",
|
||||||
|
"verified_at": "2026-10-07T10:40:00Z",
|
||||||
|
"result": "passed",
|
||||||
|
"changed_paths": [
|
||||||
|
"frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx",
|
||||||
|
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
|
||||||
|
"frontend-modern/src/components/Workloads/workloadSelectors.ts",
|
||||||
|
"frontend-modern/src/utils/resourcePolicyPresentation.ts"
|
||||||
|
],
|
||||||
|
"content_sha256": {
|
||||||
|
"frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx": "fd9ae0f141eadb0058cbf99bae19adf3d36e31008a2a35bb81e2bd02ff20d8df",
|
||||||
|
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts": "deleted",
|
||||||
|
"frontend-modern/src/components/Workloads/workloadSelectors.ts": "3ad9d22fc2e416c1224fc4db64d1dd3d99bac19b1d8d82fa947877af7a56afd7",
|
||||||
|
"frontend-modern/src/utils/resourcePolicyPresentation.ts": "e9402807382b076d0cab3e84d4f03ef3fab8f81e508a83f8304471788f001992"
|
||||||
|
},
|
||||||
|
"routes": [
|
||||||
|
"/proxmox/overview",
|
||||||
|
"/docker"
|
||||||
|
],
|
||||||
|
"viewports": [
|
||||||
|
{
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"states": [
|
||||||
|
"Embedded production build of this tree (rebased on a21e0ef8f8) served by a private DEMO_MODE mock backend (50 Proxmox nodes, 929 guests, Docker estate); the same checks also ran on an embedded build of unmodified origin/main for comparison",
|
||||||
|
"Proxmox overview workloads table at 1920: 161 guest rows, 512 Net I/O and Disk I/O values, 14 flagged as top outliers (4 semibold, 10 medium) and the rest muted, so the moved IO distribution still drives outlier emphasis",
|
||||||
|
"Proxmox overview workloads table at 390: 45 guest rows render; Net I/O and Disk I/O stay hidden at that width as before",
|
||||||
|
"Docker containers table, inline resource drawer for branch-portal-3 with its real change history plus facet counts widened to nine badges (REST list response and websocket frames edited in Playwright, since mock list rows carry recentChanges 0)",
|
||||||
|
"Drawer Change history facet summary renders all nine badges (Timeline, State transition, Restart, Config update, Anomaly, Platform event, Pulse diff, Docker adapter, Proxmox adapter) with no +N overflow chip; one line at 1440, four wrapped lines at 390, no clipped badge, right edge inside the viewport; the origin/main build renders the same nine badges and line counts",
|
||||||
|
"Console shows only the 401 and inline-style CSP messages that the origin/main build also logs on the same routes"
|
||||||
|
],
|
||||||
|
"interactions": [
|
||||||
|
"Demo auto sign-in, then direct navigation to /proxmox/overview and /docker",
|
||||||
|
"Typed branch-portal-3 into Search containers and clicked its row to open the inline resource drawer, then scrolled the Change history facet summary into view",
|
||||||
|
"Compared drawer badge count, wrap lines and overflow chip against the origin/main build on a second private backend"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -19,8 +19,6 @@ export interface ResourceFacetSummaryProps {
|
|||||||
recentChanges?: readonly ResourceChange[] | null;
|
recentChanges?: readonly ResourceChange[] | null;
|
||||||
counts?: ResourceFacetCounts | null;
|
counts?: ResourceFacetCounts | null;
|
||||||
showTimeline?: boolean;
|
showTimeline?: boolean;
|
||||||
maxVisibleBadges?: number;
|
|
||||||
class?: string;
|
|
||||||
testId?: string;
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,46 +103,17 @@ export const ResourceFacetSummary: Component<ResourceFacetSummaryProps> = (props
|
|||||||
showTimeline: props.showTimeline ?? true,
|
showTimeline: props.showTimeline ?? true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const visibleBadgeLimit = createMemo(() => {
|
|
||||||
const limit = props.maxVisibleBadges;
|
|
||||||
return typeof limit === 'number' && Number.isFinite(limit) && limit >= 0
|
|
||||||
? Math.floor(limit)
|
|
||||||
: null;
|
|
||||||
});
|
|
||||||
const visibleBadges = createMemo(() => {
|
|
||||||
const limit = visibleBadgeLimit();
|
|
||||||
return limit === null ? badges() : badges().slice(0, limit);
|
|
||||||
});
|
|
||||||
const hiddenBadges = createMemo(() => badges().slice(visibleBadges().length));
|
|
||||||
const hiddenBadgeCount = createMemo(() => hiddenBadges().length);
|
|
||||||
const hiddenBadgeTitle = createMemo(() =>
|
|
||||||
hiddenBadges()
|
|
||||||
.map((badge) => badge.title)
|
|
||||||
.join(', '),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={badges().length > 0}>
|
<Show when={badges().length > 0}>
|
||||||
<div
|
<div data-testid={props.testId ?? 'resource-facet-summary'} class="flex flex-wrap gap-1">
|
||||||
data-testid={props.testId ?? 'resource-facet-summary'}
|
<For each={badges()}>
|
||||||
class={`flex flex-wrap gap-1 ${props.class ?? ''}`}
|
|
||||||
>
|
|
||||||
<For each={visibleBadges()}>
|
|
||||||
{(badge) => (
|
{(badge) => (
|
||||||
<span class={badge.className} title={badge.title}>
|
<span class={badge.className} title={badge.title}>
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<Show when={hiddenBadgeCount() > 0}>
|
|
||||||
<span
|
|
||||||
class={`${badgeBase} bg-surface-alt text-muted`}
|
|
||||||
aria-label={`Additional resource changes: ${hiddenBadgeTitle()}`}
|
|
||||||
title={hiddenBadgeTitle()}
|
|
||||||
>
|
|
||||||
+{hiddenBadgeCount()}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,33 +37,4 @@ describe('ResourceFacetSummary', () => {
|
|||||||
expect(queryByText('Capabilities 1')).toBeNull();
|
expect(queryByText('Capabilities 1')).toBeNull();
|
||||||
expect(queryByText('Relationships 1')).toBeNull();
|
expect(queryByText('Relationships 1')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collapses badges behind an overflow chip when a visible limit is set', () => {
|
|
||||||
const { getByText, queryByText } = render(() => (
|
|
||||||
<ResourceFacetSummary
|
|
||||||
counts={{
|
|
||||||
recentChanges: 3,
|
|
||||||
recentChangeKinds: {
|
|
||||||
restart: 2,
|
|
||||||
config_update: 1,
|
|
||||||
metric_anomaly: 1,
|
|
||||||
},
|
|
||||||
recentChangeSourceTypes: {
|
|
||||||
platform_event: 1,
|
|
||||||
pulse_diff: 2,
|
|
||||||
},
|
|
||||||
recentChangeSourceAdapters: {
|
|
||||||
docker_adapter: 2,
|
|
||||||
proxmox_adapter: 1,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
recentChanges={[]}
|
|
||||||
maxVisibleBadges={1}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
|
|
||||||
expect(getByText('Timeline 3')).toBeInTheDocument();
|
|
||||||
expect(getByText('+7')).toBeInTheDocument();
|
|
||||||
expect(queryByText('Config update 1')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
-424
@@ -1,424 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { Resource } from '@/types/resource';
|
|
||||||
import {
|
|
||||||
buildInfrastructureSummaryGroupScope,
|
|
||||||
computeIOScale,
|
|
||||||
filterResources,
|
|
||||||
getOutlierEmphasis,
|
|
||||||
infrastructureHasVisibleSummaryGroupScope,
|
|
||||||
matchesSearch,
|
|
||||||
sortResources,
|
|
||||||
} from '@/components/Infrastructure/infrastructureSelectors';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Branch-coverage suite for the currently-uncovered branches of the target
|
|
||||||
* functions in infrastructureSelectors.ts. The module-private helpers
|
|
||||||
* (getSortValue, compareValues, defaultComparison, computeMedian,
|
|
||||||
* computePercentile) are NOT exported, so they are exercised exclusively
|
|
||||||
* through the exported entry points (sortResources, computeIOScale) and
|
|
||||||
* asserted on observable return values — never re-implemented or imported
|
|
||||||
* directly. The sibling infrastructureSelectors.test.ts already covers the
|
|
||||||
* happy paths; this file targets only the remaining guard/edge arms.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const makeResource = (i: number, overrides: Partial<Resource> = {}): Resource => ({
|
|
||||||
id: `resource-${i}`,
|
|
||||||
type: 'agent',
|
|
||||||
name: `host-${i}`,
|
|
||||||
displayName: `Host ${i}`,
|
|
||||||
platformId: `platform-${i}`,
|
|
||||||
platformType: 'proxmox-pve',
|
|
||||||
sourceType: 'api',
|
|
||||||
status: 'online',
|
|
||||||
lastSeen: 1_700_000_000_000 + i,
|
|
||||||
platformData: { sources: ['proxmox'] },
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const BASELINE = { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false };
|
|
||||||
const MEDIUM = { fontWeight: '500', color: 'text-base-content', showOutlierHint: true };
|
|
||||||
const HIGH = { fontWeight: '600', color: 'text-base-content', showOutlierHint: true };
|
|
||||||
|
|
||||||
describe('getSortValue (observed via sortResources)', () => {
|
|
||||||
it('sorts by uptime and substitutes 0 when uptime is absent', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Bravo', uptime: 100 }),
|
|
||||||
makeResource(2, { displayName: 'Alpha', uptime: 50 }),
|
|
||||||
makeResource(3, { displayName: 'Charlie' }), // uptime ?? 0
|
|
||||||
];
|
|
||||||
expect(sortResources(resources, 'uptime', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-3',
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts by memory percent and yields null when memory is absent', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Bravo', memory: { current: 80, total: 100, used: 80 } }),
|
|
||||||
makeResource(2, { displayName: 'Alpha', memory: { current: 20, total: 100, used: 20 } }),
|
|
||||||
makeResource(3, { displayName: 'Charlie' }), // null → bottom
|
|
||||||
];
|
|
||||||
expect(sortResources(resources, 'memory', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
'resource-3',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts by disk percent and yields null when disk is absent', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Bravo', disk: { current: 90, total: 100, used: 90 } }),
|
|
||||||
makeResource(2, { displayName: 'Alpha', disk: { current: 10, total: 100, used: 10 } }),
|
|
||||||
makeResource(3, { displayName: 'Charlie' }), // null → bottom
|
|
||||||
];
|
|
||||||
expect(sortResources(resources, 'disk', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
'resource-3',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts by network byte total and yields null when network is absent', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Bravo', network: { rxBytes: 100, txBytes: 50 } }),
|
|
||||||
makeResource(2, { displayName: 'Alpha', network: { rxBytes: 10, txBytes: 5 } }),
|
|
||||||
makeResource(3, { displayName: 'Charlie' }), // null → bottom
|
|
||||||
];
|
|
||||||
expect(sortResources(resources, 'network', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
'resource-3',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts by disk io rate total and yields null when diskIO is absent', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Bravo', diskIO: { readRate: 200, writeRate: 100 } }),
|
|
||||||
makeResource(2, { displayName: 'Alpha', diskIO: { readRate: 20, writeRate: 10 } }),
|
|
||||||
makeResource(3, { displayName: 'Charlie' }), // null → bottom
|
|
||||||
];
|
|
||||||
expect(sortResources(resources, 'diskio', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
'resource-3',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts by temperature and yields null when temperature is absent', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Bravo', temperature: 75 }),
|
|
||||||
makeResource(2, { displayName: 'Alpha', temperature: 35 }),
|
|
||||||
makeResource(3, { displayName: 'Charlie' }), // null → bottom
|
|
||||||
];
|
|
||||||
expect(sortResources(resources, 'temp', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
'resource-3',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for an unknown sort key so ordering falls back to default', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Bravo', status: 'offline' }),
|
|
||||||
makeResource(2, { displayName: 'Alpha', status: 'online' }),
|
|
||||||
];
|
|
||||||
// unknown key → getSortValue null for both → compareValues(null,null)=0
|
|
||||||
// → defaultComparison (online first)
|
|
||||||
expect(sortResources(resources, 'totally-unknown-key', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('compareValues (observed via sortResources)', () => {
|
|
||||||
it('sorts a null metric after a finite metric in asc order (aEmpty branch)', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Alpha' }), // no cpu → null
|
|
||||||
makeResource(2, { displayName: 'Bravo', cpu: { current: 42 } }),
|
|
||||||
];
|
|
||||||
expect(sortResources(resources, 'cpu', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('places a finite metric before a null metric in asc order (bEmpty branch)', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Alpha', cpu: { current: 42 } }),
|
|
||||||
makeResource(2, { displayName: 'Bravo' }), // no cpu → null
|
|
||||||
];
|
|
||||||
expect(sortResources(resources, 'cpu', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-1',
|
|
||||||
'resource-2',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats two null metric values as equal and defers to the default tie-break', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Bravo', status: 'online' }), // no cpu
|
|
||||||
makeResource(2, { displayName: 'Alpha', status: 'online' }), // no cpu
|
|
||||||
];
|
|
||||||
// compareValues(null,null) === 0 → defaultComparison → Alpha before Bravo
|
|
||||||
expect(sortResources(resources, 'cpu', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('defaultComparison (observed via sortResources default key)', () => {
|
|
||||||
it('orders two offline resources by display name via localeCompare', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Zeta', status: 'offline' }),
|
|
||||||
makeResource(2, { displayName: 'Alpha', status: 'offline' }),
|
|
||||||
];
|
|
||||||
expect(sortResources(resources, 'default', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a "stopped" status as offline via the second isResourceOnline guard', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Stopped-One', status: 'stopped' }),
|
|
||||||
makeResource(2, { displayName: 'Online-One', status: 'online' }),
|
|
||||||
];
|
|
||||||
// 'stopped' → isResourceOnline false → online resource sorts first
|
|
||||||
expect(sortResources(resources, 'default', 'asc').map((r) => r.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('matchesSearch (branch coverage)', () => {
|
|
||||||
it('returns true for an empty search term', () => {
|
|
||||||
expect(matchesSearch(makeResource(1), '')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches against identity.hostname', () => {
|
|
||||||
const resource = makeResource(1, {
|
|
||||||
displayName: 'Showcase',
|
|
||||||
identity: { hostname: 'db-prod-01.example.com' },
|
|
||||||
});
|
|
||||||
expect(matchesSearch(resource, 'db-prod-01')).toBe(true);
|
|
||||||
expect(matchesSearch(resource, 'EXAMPLE.COM')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not crash and still matches by name/id when identity and tags are absent', () => {
|
|
||||||
const resource = makeResource(1, { identity: undefined });
|
|
||||||
expect(matchesSearch(resource, 'resource-1')).toBe(true);
|
|
||||||
expect(matchesSearch(resource, 'host')).toBe(true);
|
|
||||||
expect(matchesSearch(resource, 'no-such-term')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('filterResources (branch coverage)', () => {
|
|
||||||
it('drops resources that carry no platform sources when a sources filter is active', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { platformData: { sources: ['proxmox'] } }),
|
|
||||||
makeResource(2, { platformData: {} }), // resourceSources.length === 0 → excluded
|
|
||||||
makeResource(3, { platformData: { sources: ['docker'] } }),
|
|
||||||
];
|
|
||||||
expect(
|
|
||||||
filterResources(resources, new Set(['proxmox-pve', 'docker']), new Set(), []).map(
|
|
||||||
(r) => r.id,
|
|
||||||
),
|
|
||||||
).toEqual(['resource-1', 'resource-3']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('excludes resources whose sources do not intersect the active source filter', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { platformData: { sources: ['proxmox'] } }),
|
|
||||||
makeResource(2, { platformData: { sources: ['docker'] } }),
|
|
||||||
];
|
|
||||||
expect(
|
|
||||||
filterResources(resources, new Set(['proxmox-pve']), new Set(), []).map((r) => r.id),
|
|
||||||
).toEqual(['resource-1']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes a missing status to "unknown" and filters against it', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { status: 'online' }),
|
|
||||||
makeResource(2, { status: '' as unknown as Resource['status'] }), // || 'unknown'
|
|
||||||
makeResource(3, { status: 'degraded' }),
|
|
||||||
];
|
|
||||||
expect(
|
|
||||||
filterResources(resources, new Set(), new Set(['unknown']), []).map((r) => r.id),
|
|
||||||
).toEqual(['resource-2']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildInfrastructureSummaryGroupScope (branch coverage)', () => {
|
|
||||||
it('returns null when the group has no resources', () => {
|
|
||||||
expect(buildInfrastructureSummaryGroupScope({ cluster: 'alpha', resources: [] })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when every resource id is blank or whitespace', () => {
|
|
||||||
const resources = [makeResource(1, { id: ' ' }), makeResource(2, { id: '' })];
|
|
||||||
expect(buildInfrastructureSummaryGroupScope({ cluster: 'alpha', resources })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deduplicates series ids and emits a plural cluster label', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { id: 'node-a' }),
|
|
||||||
makeResource(2, { id: 'node-a' }),
|
|
||||||
makeResource(3, { id: 'node-b' }),
|
|
||||||
];
|
|
||||||
expect(buildInfrastructureSummaryGroupScope({ cluster: 'beta', resources })).toEqual({
|
|
||||||
id: 'cluster:beta',
|
|
||||||
label: 'beta (3 resources)',
|
|
||||||
seriesIds: ['node-a', 'node-b'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits a standalone label and plural resourceCount for an empty cluster', () => {
|
|
||||||
const resources = [makeResource(1, { id: 'solo-1' }), makeResource(2, { id: 'solo-2' })];
|
|
||||||
expect(buildInfrastructureSummaryGroupScope({ cluster: '', resources })).toEqual({
|
|
||||||
id: 'cluster:__standalone__',
|
|
||||||
label: 'Standalone (2 resources)',
|
|
||||||
seriesIds: ['solo-1', 'solo-2'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('infrastructureHasVisibleSummaryGroupScope (branch coverage)', () => {
|
|
||||||
const resources = [makeResource(1, { id: 'node-a' })];
|
|
||||||
|
|
||||||
it('returns false when scope is null', () => {
|
|
||||||
expect(infrastructureHasVisibleSummaryGroupScope(resources, null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when scope is undefined', () => {
|
|
||||||
expect(infrastructureHasVisibleSummaryGroupScope(resources, undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when resources is empty but scope is present', () => {
|
|
||||||
expect(
|
|
||||||
infrastructureHasVisibleSummaryGroupScope([], {
|
|
||||||
id: 'cluster:alpha',
|
|
||||||
label: 'alpha (1 resource)',
|
|
||||||
seriesIds: ['node-a'],
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('trims series ids before comparing against visible resource ids', () => {
|
|
||||||
expect(
|
|
||||||
infrastructureHasVisibleSummaryGroupScope(resources, {
|
|
||||||
id: 'cluster:alpha',
|
|
||||||
label: 'alpha (1 resource)',
|
|
||||||
seriesIds: [' node-a '],
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getOutlierEmphasis (branch coverage)', () => {
|
|
||||||
const denseStats = { median: 10, mad: 2, max: 30, p97: 20, p99: 25, count: 10 };
|
|
||||||
|
|
||||||
it('returns baseline for a non-finite value', () => {
|
|
||||||
expect(getOutlierEmphasis(Number.NaN, denseStats)).toEqual(BASELINE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns baseline when value is <= 0', () => {
|
|
||||||
expect(getOutlierEmphasis(0, denseStats)).toEqual(BASELINE);
|
|
||||||
expect(getOutlierEmphasis(-5, denseStats)).toEqual(BASELINE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns baseline when stats.max <= 0', () => {
|
|
||||||
expect(getOutlierEmphasis(5, { median: 0, mad: 0, max: 0, p97: 0, p99: 0, count: 5 })).toEqual(
|
|
||||||
BASELINE,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('small sample path (count < 4)', () => {
|
|
||||||
const smallStats = { median: 50, mad: 0, max: 100, p97: 99, p99: 99, count: 3 };
|
|
||||||
|
|
||||||
it('returns medium emphasis at and above the 0.995 ratio boundary', () => {
|
|
||||||
expect(getOutlierEmphasis(100, smallStats)).toEqual(MEDIUM); // ratio 1.0
|
|
||||||
expect(getOutlierEmphasis(99.5, smallStats)).toEqual(MEDIUM); // ratio 0.995
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns baseline just below the 0.995 ratio boundary', () => {
|
|
||||||
expect(getOutlierEmphasis(99.4, smallStats)).toEqual(BASELINE); // ratio 0.994
|
|
||||||
expect(getOutlierEmphasis(50, smallStats)).toEqual(BASELINE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('flat distribution path (mad === 0, count >= 4)', () => {
|
|
||||||
const flatStats = { median: 50, mad: 0, max: 100, p97: 80, p99: 90, count: 5 };
|
|
||||||
|
|
||||||
it('returns high emphasis when value >= p99', () => {
|
|
||||||
expect(getOutlierEmphasis(90, flatStats)).toEqual(HIGH);
|
|
||||||
expect(getOutlierEmphasis(95, flatStats)).toEqual(HIGH);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns medium emphasis when value >= p97 but < p99', () => {
|
|
||||||
expect(getOutlierEmphasis(80, flatStats)).toEqual(MEDIUM);
|
|
||||||
expect(getOutlierEmphasis(85, flatStats)).toEqual(MEDIUM);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns baseline when value > 0 but < p97', () => {
|
|
||||||
expect(getOutlierEmphasis(70, flatStats)).toEqual(BASELINE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns baseline when modifiedZ is high but value sits below p97 (both && guards fail)', () => {
|
|
||||||
// median 10, mad 0.1, value 15 → modifiedZ = 0.6745 * 50 = 33.7 (>= 6.5)
|
|
||||||
// but 15 < p99(25) and 15 < p97(20) → neither outlier guard passes.
|
|
||||||
const stats = { median: 10, mad: 0.1, max: 30, p97: 20, p99: 25, count: 10 };
|
|
||||||
expect(getOutlierEmphasis(15, stats)).toEqual(BASELINE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('computeMedian / computePercentile (observed via computeIOScale)', () => {
|
|
||||||
it('averages the two middle values for an even count of samples', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, {
|
|
||||||
network: { rxBytes: 10, txBytes: 0 }, // total 10
|
|
||||||
diskIO: { readRate: 1, writeRate: 0 }, // total 1
|
|
||||||
}),
|
|
||||||
makeResource(2, {
|
|
||||||
network: { rxBytes: 20, txBytes: 0 }, // total 20
|
|
||||||
diskIO: { readRate: 3, writeRate: 0 }, // total 3
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
expect(computeIOScale(resources)).toEqual({
|
|
||||||
network: { median: 15, mad: 5, max: 20, p97: 20, p99: 20, count: 2 },
|
|
||||||
diskIO: { median: 2, mad: 1, max: 3, p97: 3, p99: 3, count: 2 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('selects the upper percentile element for a larger odd-length sample', () => {
|
|
||||||
const values = [2, 4, 6, 8, 100];
|
|
||||||
const resources = values.map((rxBytes, i) =>
|
|
||||||
makeResource(i + 1, { network: { rxBytes, txBytes: 0 } }),
|
|
||||||
);
|
|
||||||
expect(computeIOScale(resources).network).toEqual({
|
|
||||||
median: 6, // sorted[2]
|
|
||||||
mad: 2, // deviations from 6: 4,2,0,2,94 → sorted 0,2,2,4,94 → median 2
|
|
||||||
max: 100,
|
|
||||||
p97: 100, // index = ceil(0.97 * 5) - 1 = 4
|
|
||||||
p99: 100,
|
|
||||||
count: 5,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports zeroed diskIO stats when no resource contributes diskIO samples', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { network: { rxBytes: 5, txBytes: 5 } }),
|
|
||||||
makeResource(2, { network: { rxBytes: 15, txBytes: 5 } }),
|
|
||||||
];
|
|
||||||
expect(computeIOScale(resources).diskIO).toEqual({
|
|
||||||
median: 0,
|
|
||||||
mad: 0,
|
|
||||||
max: 0,
|
|
||||||
p97: 0,
|
|
||||||
p99: 0,
|
|
||||||
count: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-473
@@ -1,473 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { Resource } from '@/types/resource';
|
|
||||||
import type { IODistributionStats } from '@/components/Infrastructure/infrastructureSelectors';
|
|
||||||
import {
|
|
||||||
buildInfrastructureSummaryGroupId,
|
|
||||||
buildInfrastructureSummaryGroupScope,
|
|
||||||
buildStatusOptions,
|
|
||||||
collectAvailableSources,
|
|
||||||
collectAvailableStatuses,
|
|
||||||
computeIOScale,
|
|
||||||
filterResources,
|
|
||||||
getOutlierEmphasis,
|
|
||||||
getResourceSources,
|
|
||||||
groupResources,
|
|
||||||
infrastructureHasVisibleSummaryGroupScope,
|
|
||||||
matchesSearch,
|
|
||||||
sortResources,
|
|
||||||
splitPrimaryAndServiceResources,
|
|
||||||
tokenizeSearch,
|
|
||||||
} from '@/components/Infrastructure/infrastructureSelectors';
|
|
||||||
import { normalizeSourcePlatformKey } from '@/utils/sourcePlatforms';
|
|
||||||
|
|
||||||
const makeResource = (i: number, overrides: Partial<Resource> = {}): Resource => ({
|
|
||||||
id: `resource-${i}`,
|
|
||||||
type: 'agent',
|
|
||||||
name: `host-${i}`,
|
|
||||||
displayName: `Host ${i}`,
|
|
||||||
platformId: `platform-${i}`,
|
|
||||||
platformType: 'proxmox-pve',
|
|
||||||
sourceType: 'api',
|
|
||||||
status: 'online',
|
|
||||||
lastSeen: 1_700_000_000_000 + i,
|
|
||||||
platformData: { sources: ['proxmox'] },
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('infrastructureSelectors', () => {
|
|
||||||
describe('tokenizeSearch', () => {
|
|
||||||
it('handles empty and whitespace-only queries', () => {
|
|
||||||
expect(tokenizeSearch('')).toEqual([]);
|
|
||||||
expect(tokenizeSearch(' ')).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tokenizes and normalizes single and multi-word input', () => {
|
|
||||||
expect(tokenizeSearch('Host')).toEqual(['host']);
|
|
||||||
expect(tokenizeSearch(' Alpha Beta GAMMA ')).toEqual(['alpha', 'beta', 'gamma']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('matchesSearch', () => {
|
|
||||||
const resource = makeResource(1, {
|
|
||||||
id: 'node-123',
|
|
||||||
name: 'alpha-node',
|
|
||||||
displayName: 'Alpha Node',
|
|
||||||
identity: { hostname: 'alpha.local', ips: ['10.0.0.10', '192.168.1.10'] },
|
|
||||||
tags: ['edge', 'database'],
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches by name/display/id', () => {
|
|
||||||
expect(matchesSearch(resource, 'alpha')).toBe(true);
|
|
||||||
expect(matchesSearch(resource, 'NODE-123')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
|
||||||
policy: {
|
|
||||||
sensitivity: 'restricted',
|
|
||||||
routing: { scope: 'local-only', redact: ['hostname'] },
|
|
||||||
},
|
|
||||||
aiSafeSummary: 'Production Node',
|
|
||||||
});
|
|
||||||
|
|
||||||
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', () => {
|
|
||||||
expect(matchesSearch(resource, '192.168.1.10')).toBe(true);
|
|
||||||
expect(matchesSearch(resource, 'database')).toBe(true);
|
|
||||||
expect(matchesSearch(resource, 'not-found')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getResourceSources and collectors', () => {
|
|
||||||
it('normalizes legacy infrastructure source aliases to canonical platform keys', () => {
|
|
||||||
expect(normalizeSourcePlatformKey('proxmox')).toBe('proxmox-pve');
|
|
||||||
expect(normalizeSourcePlatformKey('pbs')).toBe('proxmox-pbs');
|
|
||||||
expect(normalizeSourcePlatformKey('pmg')).toBe('proxmox-pmg');
|
|
||||||
expect(normalizeSourcePlatformKey('k8s')).toBe('kubernetes');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes and deduplicates resource sources', () => {
|
|
||||||
const resource = makeResource(1, {
|
|
||||||
platformData: {
|
|
||||||
sources: ['PVE', 'proxmox-pve', 'k8s', 'kubernetes', 'network-endpoint', 'invalid'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(getResourceSources(resource)).toEqual(['proxmox-pve', 'kubernetes', 'availability']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collects available sources and statuses with deduplication', () => {
|
|
||||||
const uppercaseStatus = makeResource(3, {
|
|
||||||
status: 'ONLINE' as unknown as Resource['status'],
|
|
||||||
});
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { status: 'online', platformData: { sources: ['proxmox', 'pve'] } }),
|
|
||||||
makeResource(2, { status: 'offline', platformData: { sources: ['docker', 'docker'] } }),
|
|
||||||
makeResource(4, { status: 'online', platformData: { sources: ['availability'] } }),
|
|
||||||
uppercaseStatus,
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(collectAvailableSources(resources)).toEqual(
|
|
||||||
new Set(['proxmox-pve', 'docker', 'availability']),
|
|
||||||
);
|
|
||||||
expect(collectAvailableStatuses(resources)).toEqual(new Set(['online', 'offline']));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildStatusOptions', () => {
|
|
||||||
it('orders by predefined status order and labels known statuses', () => {
|
|
||||||
const statuses = new Set(['running', 'foo', 'offline', 'online']);
|
|
||||||
expect(buildStatusOptions(statuses)).toEqual([
|
|
||||||
{ key: 'online', label: 'Online' },
|
|
||||||
{ key: 'offline', label: 'Offline' },
|
|
||||||
{ key: 'running', label: 'Running' },
|
|
||||||
{ key: 'foo', label: 'foo' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps unknown statuses sortable without widening the canonical order contract', () => {
|
|
||||||
const statuses = new Set(['custom-state', 'paused', 'degraded']);
|
|
||||||
expect(buildStatusOptions(statuses)).toEqual([
|
|
||||||
{ key: 'degraded', label: 'Degraded' },
|
|
||||||
{ key: 'paused', label: 'Paused' },
|
|
||||||
{ key: 'custom-state', label: 'custom-state' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('filterResources', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, {
|
|
||||||
name: 'alpha-node',
|
|
||||||
status: 'online',
|
|
||||||
identity: { ips: ['10.0.0.1'] },
|
|
||||||
platformData: { sources: ['proxmox'] },
|
|
||||||
}),
|
|
||||||
makeResource(2, {
|
|
||||||
name: 'beta-node',
|
|
||||||
status: 'offline',
|
|
||||||
identity: { ips: ['10.0.0.2'] },
|
|
||||||
platformData: { sources: ['docker'] },
|
|
||||||
}),
|
|
||||||
makeResource(3, {
|
|
||||||
name: 'gamma-node',
|
|
||||||
displayName: 'Gamma Node',
|
|
||||||
status: 'degraded',
|
|
||||||
tags: ['prod', 'db'],
|
|
||||||
platformData: { sources: ['k8s'] },
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
it('returns original array when no filters are active', () => {
|
|
||||||
const result = filterResources(resources, new Set(), new Set(), []);
|
|
||||||
expect(result).toBe(resources);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters by sources only', () => {
|
|
||||||
const result = filterResources(resources, new Set(['docker']), new Set(), []);
|
|
||||||
expect(result.map((resource) => resource.id)).toEqual(['resource-2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters by statuses only', () => {
|
|
||||||
const result = filterResources(resources, new Set(), new Set(['degraded']), []);
|
|
||||||
expect(result.map((resource) => resource.id)).toEqual(['resource-3']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters by search terms only', () => {
|
|
||||||
const result = filterResources(resources, new Set(), new Set(), ['10.0.0.2']);
|
|
||||||
expect(result.map((resource) => resource.id)).toEqual(['resource-2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies combined filters and requires all terms to match', () => {
|
|
||||||
const result = filterResources(resources, new Set(['kubernetes']), new Set(['degraded']), [
|
|
||||||
'gamma',
|
|
||||||
'db',
|
|
||||||
]);
|
|
||||||
expect(result.map((resource) => resource.id)).toEqual(['resource-3']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('splitPrimaryAndServiceResources', () => {
|
|
||||||
it('splits pbs/pmg as services and everything else as primary resources', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { type: 'agent' }),
|
|
||||||
makeResource(2, { type: 'agent' }),
|
|
||||||
makeResource(3, { type: 'pbs' }),
|
|
||||||
makeResource(4, { type: 'pmg' }),
|
|
||||||
];
|
|
||||||
const { primaryResources, services } = splitPrimaryAndServiceResources(resources);
|
|
||||||
expect(primaryResources.map((resource) => resource.id)).toEqual(['resource-1', 'resource-2']);
|
|
||||||
expect(services.map((resource) => resource.id)).toEqual(['resource-3', 'resource-4']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sortResources', () => {
|
|
||||||
it('uses default sort: online first, then display name', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { status: 'offline', displayName: 'Alpha' }),
|
|
||||||
makeResource(2, { status: 'online', displayName: 'Beta' }),
|
|
||||||
makeResource(3, { status: 'online', displayName: 'Alpha' }),
|
|
||||||
];
|
|
||||||
const sorted = sortResources(resources, 'default', 'asc');
|
|
||||||
expect(sorted.map((resource) => resource.id)).toEqual([
|
|
||||||
'resource-3',
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts by name in ascending order', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Charlie' }),
|
|
||||||
makeResource(2, { displayName: 'alpha' }),
|
|
||||||
makeResource(3, { displayName: 'Bravo' }),
|
|
||||||
];
|
|
||||||
const sorted = sortResources(resources, 'name', 'asc');
|
|
||||||
expect(sorted.map((resource) => resource.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-3',
|
|
||||||
'resource-1',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts the visible system column by displayed infrastructure identity', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, {
|
|
||||||
displayName: 'PVE hybrid',
|
|
||||||
platformType: 'proxmox-pve',
|
|
||||||
sourceType: 'hybrid',
|
|
||||||
}),
|
|
||||||
makeResource(2, {
|
|
||||||
displayName: 'Docker only',
|
|
||||||
type: 'docker-host',
|
|
||||||
platformType: 'docker',
|
|
||||||
sourceType: 'api',
|
|
||||||
platformData: { sources: ['docker'] },
|
|
||||||
}),
|
|
||||||
makeResource(3, {
|
|
||||||
displayName: 'Unraid runtime',
|
|
||||||
type: 'docker-host',
|
|
||||||
platformType: 'docker',
|
|
||||||
sourceType: 'hybrid',
|
|
||||||
platformData: {
|
|
||||||
sources: ['agent', 'docker'],
|
|
||||||
agent: {
|
|
||||||
platform: 'linux',
|
|
||||||
hostProfile: 'unraid',
|
|
||||||
osName: 'Unraid',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const sorted = sortResources(resources, 'source', 'asc');
|
|
||||||
expect(sorted.map((resource) => resource.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
'resource-3',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts cpu with direction toggle and keeps default tie-breaker', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, { displayName: 'Bravo', cpu: { current: 80 } }),
|
|
||||||
makeResource(2, { displayName: 'Alpha', cpu: { current: 80 } }),
|
|
||||||
makeResource(3, { displayName: 'Charlie', cpu: { current: 20 } }),
|
|
||||||
];
|
|
||||||
|
|
||||||
const asc = sortResources(resources, 'cpu', 'asc');
|
|
||||||
expect(asc.map((resource) => resource.id)).toEqual([
|
|
||||||
'resource-3',
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const desc = sortResources(resources, 'cpu', 'desc');
|
|
||||||
expect(desc.map((resource) => resource.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
'resource-3',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts by aggregate agent disks when the canonical disk summary is absent', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, {
|
|
||||||
displayName: 'Higher usage',
|
|
||||||
agent: { disks: [{ total: 100, used: 80, free: 20 }] },
|
|
||||||
}),
|
|
||||||
makeResource(2, {
|
|
||||||
displayName: 'Lower usage',
|
|
||||||
agent: {
|
|
||||||
disks: [
|
|
||||||
{ total: 100, used: 10, free: 90 },
|
|
||||||
{ total: 300, used: 30, free: 270 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(sortResources(resources, 'disk', 'asc').map((resource) => resource.id)).toEqual([
|
|
||||||
'resource-2',
|
|
||||||
'resource-1',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('groupResources', () => {
|
|
||||||
const sortedResources = [
|
|
||||||
makeResource(1, { clusterId: 'zeta' }),
|
|
||||||
makeResource(2, { clusterId: 'alpha' }),
|
|
||||||
makeResource(3, { clusterId: '' }),
|
|
||||||
makeResource(4, {}),
|
|
||||||
];
|
|
||||||
|
|
||||||
it('returns single group in flat mode', () => {
|
|
||||||
expect(groupResources(sortedResources, 'flat')).toEqual([
|
|
||||||
{ cluster: '', resources: sortedResources },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('groups by cluster and sorts clusters with empty cluster last', () => {
|
|
||||||
expect(groupResources(sortedResources, 'grouped')).toEqual([
|
|
||||||
{ cluster: 'alpha', resources: [sortedResources[1]] },
|
|
||||||
{ cluster: 'zeta', resources: [sortedResources[0]] },
|
|
||||||
{ cluster: '', resources: [sortedResources[2], sortedResources[3]] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds canonical summary group scopes for cluster and standalone headers', () => {
|
|
||||||
const [alphaGroup, standaloneGroup] = [
|
|
||||||
{ cluster: 'alpha', resources: [sortedResources[1]] },
|
|
||||||
{ cluster: '', resources: [sortedResources[2], sortedResources[3]] },
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(buildInfrastructureSummaryGroupId('alpha')).toBe('cluster:alpha');
|
|
||||||
expect(buildInfrastructureSummaryGroupId('')).toBe('cluster:__standalone__');
|
|
||||||
expect(buildInfrastructureSummaryGroupScope(alphaGroup)).toEqual({
|
|
||||||
id: 'cluster:alpha',
|
|
||||||
label: 'alpha (1 resource)',
|
|
||||||
seriesIds: ['resource-2'],
|
|
||||||
});
|
|
||||||
expect(buildInfrastructureSummaryGroupScope(standaloneGroup)).toEqual({
|
|
||||||
id: 'cluster:__standalone__',
|
|
||||||
label: 'Standalone (2 resources)',
|
|
||||||
seriesIds: ['resource-3', 'resource-4'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops stale summary group hover once none of its resources are still visible', () => {
|
|
||||||
const scope = buildInfrastructureSummaryGroupScope({
|
|
||||||
cluster: 'alpha',
|
|
||||||
resources: [sortedResources[1]],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(infrastructureHasVisibleSummaryGroupScope(sortedResources, scope)).toBe(true);
|
|
||||||
expect(
|
|
||||||
infrastructureHasVisibleSummaryGroupScope(
|
|
||||||
sortedResources.filter((resource) => resource.id !== 'resource-2'),
|
|
||||||
scope,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('computeIOScale', () => {
|
|
||||||
it('computes expected stats for network and disk io', () => {
|
|
||||||
const resources = [
|
|
||||||
makeResource(1, {
|
|
||||||
network: { rxBytes: 1, txBytes: 1 },
|
|
||||||
diskIO: { readRate: 1, writeRate: 1 },
|
|
||||||
}),
|
|
||||||
makeResource(2, {
|
|
||||||
network: { rxBytes: 3, txBytes: 1 },
|
|
||||||
diskIO: { readRate: 2, writeRate: 2 },
|
|
||||||
}),
|
|
||||||
makeResource(3, {}),
|
|
||||||
makeResource(4, {
|
|
||||||
network: { rxBytes: 10, txBytes: 0 },
|
|
||||||
diskIO: { readRate: 8, writeRate: 0 },
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(computeIOScale(resources)).toEqual({
|
|
||||||
network: { median: 4, mad: 2, max: 10, p97: 10, p99: 10, count: 3 },
|
|
||||||
diskIO: { median: 4, mad: 2, max: 8, p97: 8, p99: 8, count: 3 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty and single-resource inputs', () => {
|
|
||||||
expect(computeIOScale([])).toEqual({
|
|
||||||
network: { median: 0, mad: 0, max: 0, p97: 0, p99: 0, count: 0 },
|
|
||||||
diskIO: { median: 0, mad: 0, max: 0, p97: 0, p99: 0, count: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
computeIOScale([
|
|
||||||
makeResource(1, {
|
|
||||||
network: { rxBytes: 7, txBytes: 3 },
|
|
||||||
diskIO: { readRate: 11, writeRate: 9 },
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
).toEqual({
|
|
||||||
network: { median: 10, mad: 0, max: 10, p97: 10, p99: 10, count: 1 },
|
|
||||||
diskIO: { median: 20, mad: 0, max: 20, p97: 20, p99: 20, count: 1 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getOutlierEmphasis', () => {
|
|
||||||
const stats: IODistributionStats = {
|
|
||||||
median: 10,
|
|
||||||
mad: 2,
|
|
||||||
max: 30,
|
|
||||||
p97: 20,
|
|
||||||
p99: 25,
|
|
||||||
count: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
it('returns baseline styling below threshold', () => {
|
|
||||||
expect(getOutlierEmphasis(12, stats)).toEqual({
|
|
||||||
fontWeight: 'normal',
|
|
||||||
color: 'text-muted',
|
|
||||||
showOutlierHint: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns medium emphasis for moderate outliers', () => {
|
|
||||||
expect(getOutlierEmphasis(27, stats)).toEqual({
|
|
||||||
fontWeight: '500',
|
|
||||||
color: 'text-base-content',
|
|
||||||
showOutlierHint: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns high emphasis for strong outliers', () => {
|
|
||||||
expect(getOutlierEmphasis(30, stats)).toEqual({
|
|
||||||
fontWeight: '600',
|
|
||||||
color: 'text-base-content',
|
|
||||||
showOutlierHint: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not compare partial disk observations as complete throughput totals', () => {
|
|
||||||
const known = [
|
|
||||||
makeResource(1, { diskIO: { readRate: 0, writeRate: 0 } }),
|
|
||||||
makeResource(2, { diskIO: { readRate: 100, writeRate: 200 } }),
|
|
||||||
];
|
|
||||||
const partial = [
|
|
||||||
makeResource(3, { diskIO: { readRate: 10000 } }),
|
|
||||||
makeResource(4, { diskIO: { writeRate: 0 } }),
|
|
||||||
makeResource(5, { diskIO: {} }),
|
|
||||||
];
|
|
||||||
expect(computeIOScale([...known, ...partial]).diskIO).toEqual(computeIOScale(known).diskIO);
|
|
||||||
});
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
import type { Resource } from '@/types/resource';
|
|
||||||
import { getCpuPercent, getMemoryPercent } from '@/types/resource';
|
|
||||||
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';
|
|
||||||
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
|
|
||||||
import { getResourceDiskSummary } from '@/utils/format';
|
|
||||||
|
|
||||||
export interface IODistributionStats {
|
|
||||||
median: number;
|
|
||||||
mad: number;
|
|
||||||
max: number;
|
|
||||||
p97: number;
|
|
||||||
p99: number;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OutlierEmphasis {
|
|
||||||
fontWeight: string;
|
|
||||||
color: string;
|
|
||||||
showOutlierHint: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResourceGroup {
|
|
||||||
cluster: string;
|
|
||||||
resources: Resource[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const INFRASTRUCTURE_STANDALONE_SUMMARY_GROUP_ID = 'cluster:__standalone__';
|
|
||||||
|
|
||||||
const isServiceInfrastructureResource = (resource: Resource) =>
|
|
||||||
resource.type === 'pbs' || resource.type === 'pmg';
|
|
||||||
|
|
||||||
const isResourceOnline = (resource: Resource) => {
|
|
||||||
const status = resource.status?.toLowerCase();
|
|
||||||
return status !== 'offline' && status !== 'stopped';
|
|
||||||
};
|
|
||||||
|
|
||||||
const computeMedian = (values: number[]): number => {
|
|
||||||
if (values.length === 0) return 0;
|
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
|
||||||
const mid = Math.floor(sorted.length / 2);
|
|
||||||
if (sorted.length % 2 === 0) {
|
|
||||||
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
||||||
}
|
|
||||||
return sorted[mid];
|
|
||||||
};
|
|
||||||
|
|
||||||
const computePercentile = (values: number[], percentile: number): number => {
|
|
||||||
if (values.length === 0) return 0;
|
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
|
||||||
const clamped = Math.max(0, Math.min(1, percentile));
|
|
||||||
const index = Math.max(0, Math.min(sorted.length - 1, Math.ceil(clamped * sorted.length) - 1));
|
|
||||||
return sorted[index];
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildIODistribution = (values: number[]): IODistributionStats => {
|
|
||||||
const valid = values.filter((value) => Number.isFinite(value) && value >= 0);
|
|
||||||
if (valid.length === 0) {
|
|
||||||
return { median: 0, mad: 0, max: 0, p97: 0, p99: 0, count: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const median = computeMedian(valid);
|
|
||||||
const deviations = valid.map((value) => Math.abs(value - median));
|
|
||||||
const mad = computeMedian(deviations);
|
|
||||||
const max = Math.max(...valid, 0);
|
|
||||||
const p97 = computePercentile(valid, 0.97);
|
|
||||||
const p99 = computePercentile(valid, 0.99);
|
|
||||||
|
|
||||||
return { median, mad, max, p97, p99, count: valid.length };
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSortValue = (resource: Resource, key: string): number | string | null => {
|
|
||||||
switch (key) {
|
|
||||||
case 'name':
|
|
||||||
return getPreferredInfrastructureDisplayName(resource);
|
|
||||||
case 'uptime':
|
|
||||||
return resource.uptime ?? 0;
|
|
||||||
case 'cpu':
|
|
||||||
return resource.cpu ? getCpuPercent(resource) : null;
|
|
||||||
case 'memory':
|
|
||||||
return resource.memory ? getMemoryPercent(resource) : null;
|
|
||||||
case 'disk':
|
|
||||||
return getResourceDiskSummary(resource)?.usage ?? null;
|
|
||||||
case 'network':
|
|
||||||
return resource.network ? resource.network.rxBytes + resource.network.txBytes : null;
|
|
||||||
case 'diskio':
|
|
||||||
return resource.diskIO?.readRate !== undefined && resource.diskIO?.writeRate !== undefined
|
|
||||||
? resource.diskIO.readRate + resource.diskIO.writeRate
|
|
||||||
: null;
|
|
||||||
case 'source':
|
|
||||||
return getInfrastructureSystemIdentitySortLabel(resource);
|
|
||||||
case 'temp':
|
|
||||||
return resource.temperature ?? null;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultComparison = (a: Resource, b: Resource) => {
|
|
||||||
const aOnline = isResourceOnline(a);
|
|
||||||
const bOnline = isResourceOnline(b);
|
|
||||||
if (aOnline !== bOnline) return aOnline ? -1 : 1;
|
|
||||||
return getPreferredInfrastructureDisplayName(a).localeCompare(
|
|
||||||
getPreferredInfrastructureDisplayName(b),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const compareValues = (valueA: number | string | null, valueB: number | string | null) => {
|
|
||||||
const aEmpty =
|
|
||||||
valueA === null || valueA === undefined || (typeof valueA === 'number' && Number.isNaN(valueA));
|
|
||||||
const bEmpty =
|
|
||||||
valueB === null || valueB === undefined || (typeof valueB === 'number' && Number.isNaN(valueB));
|
|
||||||
|
|
||||||
if (aEmpty && bEmpty) return 0;
|
|
||||||
if (aEmpty) return 1;
|
|
||||||
if (bEmpty) return -1;
|
|
||||||
|
|
||||||
if (typeof valueA === 'number' && typeof valueB === 'number') {
|
|
||||||
if (valueA === valueB) return 0;
|
|
||||||
return valueA < valueB ? -1 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aStr = String(valueA).toLowerCase();
|
|
||||||
const bStr = String(valueB).toLowerCase();
|
|
||||||
|
|
||||||
if (aStr === bStr) return 0;
|
|
||||||
return aStr < bStr ? -1 : 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tokenizeSearch = (query: string): string[] =>
|
|
||||||
query
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((term) => term.length > 0);
|
|
||||||
|
|
||||||
export const matchesSearch = (resource: Resource, term: string): boolean => {
|
|
||||||
if (!term) return true;
|
|
||||||
const normalizedTerm = term.toLowerCase();
|
|
||||||
const candidates: string[] = [
|
|
||||||
// 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 ?? []),
|
|
||||||
...(resource.tags ?? []),
|
|
||||||
];
|
|
||||||
const haystack = candidates
|
|
||||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase();
|
|
||||||
return haystack.includes(normalizedTerm);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getResourceSources = (resource: Resource): KnownSourcePlatform[] => {
|
|
||||||
const platformData = resource.platformData as { sources?: string[] } | undefined;
|
|
||||||
const normalized = (platformData?.sources ?? [])
|
|
||||||
.map((source) => normalizeSourcePlatformKey(source))
|
|
||||||
.filter((source): source is KnownSourcePlatform => Boolean(source));
|
|
||||||
return Array.from(new Set(normalized));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const collectAvailableSources = (resources: Resource[]): Set<KnownSourcePlatform> => {
|
|
||||||
const set = new Set<KnownSourcePlatform>();
|
|
||||||
resources.forEach((resource) => {
|
|
||||||
getResourceSources(resource).forEach((source) => set.add(source));
|
|
||||||
});
|
|
||||||
return set;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const collectAvailableStatuses = (resources: Resource[]): Set<string> => {
|
|
||||||
const set = new Set<string>();
|
|
||||||
resources.forEach((resource) => {
|
|
||||||
const status = (resource.status || 'unknown').toLowerCase();
|
|
||||||
if (status) set.add(status);
|
|
||||||
});
|
|
||||||
return set;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildStatusOptions = (
|
|
||||||
statuses: Set<string>,
|
|
||||||
): Array<{ key: string; label: string }> => {
|
|
||||||
const items = Array.from(statuses);
|
|
||||||
const getStatusSortIndex = (status: string): number => {
|
|
||||||
if (STATUS_SORT_ORDER.includes(status as (typeof STATUS_SORT_ORDER)[number])) {
|
|
||||||
return STATUS_SORT_ORDER.indexOf(status as (typeof STATUS_SORT_ORDER)[number]);
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
};
|
|
||||||
items.sort((a, b) => {
|
|
||||||
const indexA = getStatusSortIndex(a);
|
|
||||||
const indexB = getStatusSortIndex(b);
|
|
||||||
if (indexA === -1 && indexB === -1) return a.localeCompare(b);
|
|
||||||
if (indexA === -1) return 1;
|
|
||||||
if (indexB === -1) return -1;
|
|
||||||
return indexA - indexB;
|
|
||||||
});
|
|
||||||
return items.map((status) => ({
|
|
||||||
key: status,
|
|
||||||
label: getCanonicalStatusLabel(status, status),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const filterResources = (
|
|
||||||
resources: Resource[],
|
|
||||||
sources: Set<string>,
|
|
||||||
statuses: Set<string>,
|
|
||||||
searchTerms: string[],
|
|
||||||
): Resource[] => {
|
|
||||||
let filtered = resources;
|
|
||||||
|
|
||||||
if (sources.size > 0) {
|
|
||||||
filtered = filtered.filter((resource) => {
|
|
||||||
const resourceSources = getResourceSources(resource);
|
|
||||||
if (resourceSources.length === 0) return false;
|
|
||||||
return resourceSources.some((source) => sources.has(source));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statuses.size > 0) {
|
|
||||||
filtered = filtered.filter((resource) => {
|
|
||||||
const status = (resource.status || 'unknown').toLowerCase();
|
|
||||||
return statuses.has(status);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchTerms.length > 0) {
|
|
||||||
filtered = filtered.filter((resource) =>
|
|
||||||
searchTerms.every((term) => matchesSearch(resource, term)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const splitPrimaryAndServiceResources = (
|
|
||||||
resources: Resource[],
|
|
||||||
): { primaryResources: Resource[]; services: Resource[] } => ({
|
|
||||||
primaryResources: resources.filter((resource) => !isServiceInfrastructureResource(resource)),
|
|
||||||
services: resources.filter((resource) => isServiceInfrastructureResource(resource)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const sortResources = (
|
|
||||||
resources: Resource[],
|
|
||||||
sortKey: string,
|
|
||||||
sortDirection: 'asc' | 'desc',
|
|
||||||
): Resource[] => {
|
|
||||||
const list = [...resources];
|
|
||||||
return list.sort((a, b) => {
|
|
||||||
if (sortKey === 'default') {
|
|
||||||
return defaultComparison(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueA = getSortValue(a, sortKey);
|
|
||||||
const valueB = getSortValue(b, sortKey);
|
|
||||||
const comparison = compareValues(valueA, valueB);
|
|
||||||
|
|
||||||
if (comparison !== 0) {
|
|
||||||
return sortDirection === 'asc' ? comparison : -comparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultComparison(a, b);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const groupResources = (
|
|
||||||
sortedResources: Resource[],
|
|
||||||
mode: 'grouped' | 'flat',
|
|
||||||
): ResourceGroup[] => {
|
|
||||||
if (mode !== 'grouped') {
|
|
||||||
return [{ cluster: '', resources: sortedResources }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = new Map<string, Resource[]>();
|
|
||||||
for (const resource of sortedResources) {
|
|
||||||
const cluster = resource.clusterId || '';
|
|
||||||
const list = groups.get(cluster);
|
|
||||||
if (list) {
|
|
||||||
list.push(resource);
|
|
||||||
} else {
|
|
||||||
groups.set(cluster, [resource]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = Array.from(groups.entries()).map(([cluster, resources]) => ({
|
|
||||||
cluster,
|
|
||||||
resources,
|
|
||||||
}));
|
|
||||||
entries.sort((a, b) => {
|
|
||||||
if (!a.cluster && b.cluster) return 1;
|
|
||||||
if (a.cluster && !b.cluster) return -1;
|
|
||||||
return a.cluster.localeCompare(b.cluster);
|
|
||||||
});
|
|
||||||
return entries;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildInfrastructureSummaryGroupId = (cluster: string | null | undefined): string => {
|
|
||||||
const normalizedCluster = (cluster ?? '').trim();
|
|
||||||
return normalizedCluster
|
|
||||||
? `cluster:${normalizedCluster}`
|
|
||||||
: INFRASTRUCTURE_STANDALONE_SUMMARY_GROUP_ID;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildInfrastructureSummaryGroupScope = (
|
|
||||||
group: ResourceGroup,
|
|
||||||
): SummarySeriesGroupScope | null => {
|
|
||||||
const seriesIds = Array.from(
|
|
||||||
new Set(group.resources.map((resource) => resource.id?.trim() || '').filter(Boolean)),
|
|
||||||
);
|
|
||||||
if (seriesIds.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clusterLabel = group.cluster.trim();
|
|
||||||
const resourceCountLabel = `${group.resources.length} resource${group.resources.length === 1 ? '' : 's'}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: buildInfrastructureSummaryGroupId(group.cluster),
|
|
||||||
label: clusterLabel
|
|
||||||
? `${clusterLabel} (${resourceCountLabel})`
|
|
||||||
: `Standalone (${resourceCountLabel})`,
|
|
||||||
seriesIds,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const infrastructureHasVisibleSummaryGroupScope = (
|
|
||||||
resources: Resource[],
|
|
||||||
scope: SummarySeriesGroupScope | null | undefined,
|
|
||||||
): boolean => {
|
|
||||||
if (!scope) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const visibleResourceIds = new Set(
|
|
||||||
resources.map((resource) => resource.id?.trim() || '').filter(Boolean),
|
|
||||||
);
|
|
||||||
return scope.seriesIds.some((seriesId) => visibleResourceIds.has(seriesId.trim()));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeIOScale = (
|
|
||||||
resources: Resource[],
|
|
||||||
): { network: IODistributionStats; diskIO: IODistributionStats } => {
|
|
||||||
const networkValues: number[] = [];
|
|
||||||
const diskIOValues: number[] = [];
|
|
||||||
|
|
||||||
for (const resource of resources) {
|
|
||||||
const networkTotal = (resource.network?.rxBytes ?? 0) + (resource.network?.txBytes ?? 0);
|
|
||||||
if (resource.network) {
|
|
||||||
networkValues.push(networkTotal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resource.diskIO?.readRate !== undefined && resource.diskIO?.writeRate !== undefined) {
|
|
||||||
diskIOValues.push(resource.diskIO.readRate + resource.diskIO.writeRate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
network: buildIODistribution(networkValues),
|
|
||||||
diskIO: buildIODistribution(diskIOValues),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getOutlierEmphasis = (value: number, stats: IODistributionStats): OutlierEmphasis => {
|
|
||||||
if (!Number.isFinite(value) || value <= 0 || stats.max <= 0) {
|
|
||||||
return { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.count < 4) {
|
|
||||||
const ratio = value / stats.max;
|
|
||||||
if (ratio >= 0.995) {
|
|
||||||
return { fontWeight: '500', color: 'text-base-content', showOutlierHint: true };
|
|
||||||
}
|
|
||||||
return { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.mad > 0) {
|
|
||||||
const modifiedZ = (0.6745 * (value - stats.median)) / stats.mad;
|
|
||||||
if (modifiedZ >= 6.5 && value >= stats.p99) {
|
|
||||||
return { fontWeight: '600', color: 'text-base-content', showOutlierHint: true };
|
|
||||||
}
|
|
||||||
if (modifiedZ >= 5.5 && value >= stats.p97) {
|
|
||||||
return { fontWeight: '500', color: 'text-base-content', showOutlierHint: true };
|
|
||||||
}
|
|
||||||
return { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value >= stats.p99)
|
|
||||||
return { fontWeight: '600', color: 'text-base-content', showOutlierHint: true };
|
|
||||||
if (value >= stats.p97)
|
|
||||||
return { fontWeight: '500', color: 'text-base-content', showOutlierHint: true };
|
|
||||||
if (value > 0) return { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false };
|
|
||||||
return { fontWeight: 'normal', color: 'text-muted', showOutlierHint: false };
|
|
||||||
};
|
|
||||||
-31
@@ -74,7 +74,6 @@ import {
|
|||||||
computeWorkloadStats,
|
computeWorkloadStats,
|
||||||
} from '../workloadSelectors';
|
} from '../workloadSelectors';
|
||||||
import { getKubernetesContextKey } from '../workloadTopology';
|
import { getKubernetesContextKey } from '../workloadTopology';
|
||||||
import { filterResources } from '@/components/Infrastructure/infrastructureSelectors';
|
|
||||||
import { getCanonicalWorkloadId, normalizeWorkloadViewModeParam } from '@/utils/workloads';
|
import { getCanonicalWorkloadId, normalizeWorkloadViewModeParam } from '@/utils/workloads';
|
||||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||||
|
|
||||||
@@ -350,24 +349,6 @@ const makeGuest = (i: number, overrides?: Record<string, unknown>) => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeResource = (overrides?: Partial<Resource>): Resource => ({
|
|
||||||
id: 'resource-1',
|
|
||||||
type: 'vm',
|
|
||||||
name: 'secret-host-1',
|
|
||||||
displayName: 'secret-host-1',
|
|
||||||
platformId: 'platform-1',
|
|
||||||
platformType: 'proxmox-pve',
|
|
||||||
sourceType: 'api',
|
|
||||||
status: 'running',
|
|
||||||
lastSeen: Date.now(),
|
|
||||||
policy: {
|
|
||||||
sensitivity: 'restricted',
|
|
||||||
routing: { scope: 'local-only', redact: ['hostname'] },
|
|
||||||
},
|
|
||||||
aiSafeSummary: 'Production Host',
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const PROFILES = {
|
const PROFILES = {
|
||||||
S: 400,
|
S: 400,
|
||||||
M: 1500,
|
M: 1500,
|
||||||
@@ -837,18 +818,6 @@ describe('Workloads performance contract', () => {
|
|||||||
expect(document.body).not.toHaveTextContent('Add agent for AI actions');
|
expect(document.body).not.toHaveTextContent('Add agent for AI actions');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('searches policy-redacted resources by their raw display name in operator-local UI', () => {
|
|
||||||
// owning route 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(), ['secret-host']);
|
|
||||||
const rawFiltered = filterResources(resources, new Set(), new Set(), ['Production']);
|
|
||||||
|
|
||||||
expect(filtered).toHaveLength(1);
|
|
||||||
expect(rawFiltered).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps projected pod context keys aligned with the canonical cluster label', () => {
|
it('keeps projected pod context keys aligned with the canonical cluster label', () => {
|
||||||
const guest = makeGuest(1, {
|
const guest = makeGuest(1, {
|
||||||
type: 'pod',
|
type: 'pod',
|
||||||
|
|||||||
@@ -993,5 +993,63 @@ describe('workloadSelectors', () => {
|
|||||||
diskIO: { median: 3.5, mad: 3.5, max: 7, p97: 7, p99: 7, count: 2 },
|
diskIO: { median: 3.5, mad: 3.5, max: 7, p97: 7, p99: 7, count: 2 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports zeroed stats when there are no workloads', () => {
|
||||||
|
const empty = { median: 0, mad: 0, max: 0, p97: 0, p99: 0, count: 0 };
|
||||||
|
expect(computeWorkloadIOEmphasis([])).toEqual({ network: empty, diskIO: empty });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the median absolute deviation and upper percentile for an odd sample', () => {
|
||||||
|
const guests = [2, 4, 6, 8, 100].map((networkIn, i) =>
|
||||||
|
makeGuest(i + 1, { networkIn, networkOut: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// deviations from 6 are 4,2,0,2,94, so the MAD is 2; p97 and p99 land on
|
||||||
|
// index ceil(p * 5) - 1 = 4.
|
||||||
|
expect(computeWorkloadIOEmphasis(guests).network).toEqual({
|
||||||
|
median: 6,
|
||||||
|
mad: 2,
|
||||||
|
max: 100,
|
||||||
|
p97: 100,
|
||||||
|
p99: 100,
|
||||||
|
count: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects the 97th and 99th percentile samples from a large distribution', () => {
|
||||||
|
const guests = Array.from({ length: 100 }, (_, i) =>
|
||||||
|
makeGuest(i + 1, { networkIn: i + 1, networkOut: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(computeWorkloadIOEmphasis(guests).network).toMatchObject({
|
||||||
|
median: 50.5,
|
||||||
|
max: 100,
|
||||||
|
p97: 97,
|
||||||
|
p99: 99,
|
||||||
|
count: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops non-finite throughput samples from the distribution', () => {
|
||||||
|
const guests = [
|
||||||
|
makeGuest(1, { networkIn: Number.NaN, networkOut: 0, diskRead: 0, diskWrite: 0 }),
|
||||||
|
makeGuest(2, {
|
||||||
|
networkIn: Number.POSITIVE_INFINITY,
|
||||||
|
networkOut: 0,
|
||||||
|
diskRead: 0,
|
||||||
|
diskWrite: 0,
|
||||||
|
}),
|
||||||
|
makeGuest(3, { networkIn: 10, networkOut: 0, diskRead: 0, diskWrite: 0 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(computeWorkloadIOEmphasis(guests).network).toEqual({
|
||||||
|
median: 10,
|
||||||
|
mad: 0,
|
||||||
|
max: 10,
|
||||||
|
p97: 10,
|
||||||
|
p99: 10,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { Node } from '@/types/api';
|
import type { Node } from '@/types/api';
|
||||||
import type { WorkloadGuest, ViewMode, WorkloadType } from '@/types/workloads';
|
import type { WorkloadGuest, ViewMode, WorkloadType } from '@/types/workloads';
|
||||||
import { getWorkloadInfoValue, type WorkloadIOEmphasis } from './guestRowModel';
|
import {
|
||||||
import { computeIOScale } from '@/components/Infrastructure/infrastructureSelectors';
|
getWorkloadInfoValue,
|
||||||
|
type IODistributionStats,
|
||||||
|
type WorkloadIOEmphasis,
|
||||||
|
} from './guestRowModel';
|
||||||
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
|
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
|
||||||
import { parseFilterStack, evaluateFilterStack, splitSearchExclusions } from '@/utils/searchQuery';
|
import { parseFilterStack, evaluateFilterStack, splitSearchExclusions } from '@/utils/searchQuery';
|
||||||
import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
|
import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
|
||||||
@@ -535,18 +538,47 @@ export const computeWorkloadStats = (guests: WorkloadGuest[]): WorkloadStats =>
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const computeWorkloadIOEmphasis = (guests: WorkloadGuest[]): WorkloadIOEmphasis => {
|
const computeMedian = (values: number[]): number => {
|
||||||
const resources = guests.map((guest) => ({
|
if (values.length === 0) return 0;
|
||||||
network: {
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
rxBytes: Math.max(0, guest.networkIn ?? 0),
|
const mid = Math.floor(sorted.length / 2);
|
||||||
txBytes: Math.max(0, guest.networkOut ?? 0),
|
if (sorted.length % 2 === 0) {
|
||||||
},
|
return (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
diskIO: {
|
}
|
||||||
readRate: Math.max(0, guest.diskRead ?? 0),
|
return sorted[mid];
|
||||||
writeRate: Math.max(0, guest.diskWrite ?? 0),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { network, diskIO } = computeIOScale(resources as Parameters<typeof computeIOScale>[0]);
|
|
||||||
return { network, diskIO };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const computePercentile = (values: number[], percentile: number): number => {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const clamped = Math.max(0, Math.min(1, percentile));
|
||||||
|
const index = Math.max(0, Math.min(sorted.length - 1, Math.ceil(clamped * sorted.length) - 1));
|
||||||
|
return sorted[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Median absolute deviation plus upper percentiles feed getOutlierEmphasis in
|
||||||
|
// guestRowModel; non-finite and negative samples are dropped.
|
||||||
|
const buildIODistribution = (values: number[]): IODistributionStats => {
|
||||||
|
const valid = values.filter((value) => Number.isFinite(value) && value >= 0);
|
||||||
|
if (valid.length === 0) {
|
||||||
|
return { median: 0, mad: 0, max: 0, p97: 0, p99: 0, count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const median = computeMedian(valid);
|
||||||
|
const deviations = valid.map((value) => Math.abs(value - median));
|
||||||
|
const mad = computeMedian(deviations);
|
||||||
|
const max = Math.max(...valid, 0);
|
||||||
|
const p97 = computePercentile(valid, 0.97);
|
||||||
|
const p99 = computePercentile(valid, 0.99);
|
||||||
|
|
||||||
|
return { median, mad, max, p97, p99, count: valid.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeWorkloadIOEmphasis = (guests: WorkloadGuest[]): WorkloadIOEmphasis => ({
|
||||||
|
network: buildIODistribution(
|
||||||
|
guests.map((guest) => Math.max(0, guest.networkIn ?? 0) + Math.max(0, guest.networkOut ?? 0)),
|
||||||
|
),
|
||||||
|
diskIO: buildIODistribution(
|
||||||
|
guests.map((guest) => Math.max(0, guest.diskRead ?? 0) + Math.max(0, guest.diskWrite ?? 0)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|||||||
-1
@@ -656,7 +656,6 @@ describe('platform overview layout guardrails', () => {
|
|||||||
expect(standalonePageSurfaceSource).not.toContain('UnifiedResourceTable');
|
expect(standalonePageSurfaceSource).not.toContain('UnifiedResourceTable');
|
||||||
expect(agentsMachinesTableSource).toContain('PlatformResourceDetailTableRow');
|
expect(agentsMachinesTableSource).toContain('PlatformResourceDetailTableRow');
|
||||||
expect(agentsMachinesTableSource).not.toContain('ResourceDetailDrawer');
|
expect(agentsMachinesTableSource).not.toContain('ResourceDetailDrawer');
|
||||||
expect(standalonePageModelSource).not.toContain('infrastructureSelectors');
|
|
||||||
expect(standalonePageModelSource).not.toContain('buildAgentsPageFilterModel');
|
expect(standalonePageModelSource).not.toContain('buildAgentsPageFilterModel');
|
||||||
expect(standalonePageModelSource).not.toContain('buildStandalonePageFilterModel');
|
expect(standalonePageModelSource).not.toContain('buildStandalonePageFilterModel');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ import type {
|
|||||||
import {
|
import {
|
||||||
getResourcePolicyDisplayLabel,
|
getResourcePolicyDisplayLabel,
|
||||||
getResourcePolicyGovernedSummary,
|
getResourcePolicyGovernedSummary,
|
||||||
getResourcePolicyTableBadges,
|
|
||||||
getResourceRedactionHintLabel,
|
getResourceRedactionHintLabel,
|
||||||
getResourceRoutingScopeLabel,
|
getResourceRoutingScopeLabel,
|
||||||
getResourceSensitivityLabel,
|
getResourceSensitivityLabel,
|
||||||
hasDefaultResourcePolicyPosture,
|
hasDefaultResourcePolicyPosture,
|
||||||
shouldShowResourceAlternateName,
|
|
||||||
} from '@/utils/resourcePolicyPresentation';
|
} from '@/utils/resourcePolicyPresentation';
|
||||||
import type { ResourcePolicyDisplayResource } from '@/utils/resourcePolicyPresentation';
|
import type { ResourcePolicyDisplayResource } from '@/utils/resourcePolicyPresentation';
|
||||||
|
|
||||||
@@ -22,10 +20,9 @@ import type { ResourcePolicyDisplayResource } from '@/utils/resourcePolicyPresen
|
|||||||
*
|
*
|
||||||
* The existing sibling test (`resourcePolicyPresentation.test.ts`) exercises the
|
* The existing sibling test (`resourcePolicyPresentation.test.ts`) exercises the
|
||||||
* happy paths. This file targets the defensive / fallback arms of each named
|
* happy paths. This file targets the defensive / fallback arms of each named
|
||||||
* function: undefined inputs, non-blocking postures, empty/whitespace strings,
|
* function: undefined inputs, empty/whitespace strings, the `?? hint`
|
||||||
* the `?? hint` redaction fallback, the `?? 0` redact-length arm, the
|
* redaction fallback, the `?? 0` redact-length arm, and the concise-summary
|
||||||
* sensitivity-vs-routing primary selection, and the concise-summary status
|
* status absence / all-empty-parts / no-semicolon branches.
|
||||||
* absence / all-empty-parts / no-semicolon branches.
|
|
||||||
*
|
*
|
||||||
* NOTE: `getConciseGovernedDisplaySummary` is a module-private (non-exported)
|
* NOTE: `getConciseGovernedDisplaySummary` is a module-private (non-exported)
|
||||||
* helper, so it is covered indirectly through `getResourcePolicyDisplayLabel`,
|
* helper, so it is covered indirectly through `getResourcePolicyDisplayLabel`,
|
||||||
@@ -148,57 +145,6 @@ describe('hasDefaultResourcePolicyPosture — branch coverage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getResourcePolicyTableBadges — branch coverage', () => {
|
|
||||||
it('returns an empty array when no policy is supplied', () => {
|
|
||||||
expect(getResourcePolicyTableBadges(undefined)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an empty array for a non-blocking public posture', () => {
|
|
||||||
expect(
|
|
||||||
getResourcePolicyTableBadges({
|
|
||||||
sensitivity: 'public',
|
|
||||||
routing: { scope: 'cloud-summary' },
|
|
||||||
}),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the sensitivity badge as primary when scope is not local-only (restricted + cloud-summary)', () => {
|
|
||||||
const badges = getResourcePolicyTableBadges({
|
|
||||||
sensitivity: 'restricted',
|
|
||||||
routing: { scope: 'cloud-summary' },
|
|
||||||
});
|
|
||||||
expect(badges).toHaveLength(1);
|
|
||||||
expect(badges[0]?.label).toBe('Restricted');
|
|
||||||
// No redactions -> redactionTitle is undefined and filtered out of the title.
|
|
||||||
expect(badges[0]?.title).toBe(
|
|
||||||
'Restricted: Resource data is tightly restricted and requires guarded handling. Cloud Summary: This resource may use cloud summarization within policy limits.',
|
|
||||||
);
|
|
||||||
expect(badges[0]?.title).not.toContain('Redacts');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the sensitivity badge as primary when restricted with local-first scope and redactions', () => {
|
|
||||||
const badges = getResourcePolicyTableBadges({
|
|
||||||
sensitivity: 'restricted',
|
|
||||||
routing: { scope: 'local-first', redact: ['hostname', 'path'] },
|
|
||||||
});
|
|
||||||
expect(badges).toHaveLength(1);
|
|
||||||
expect(badges[0]?.label).toBe('Restricted');
|
|
||||||
expect(badges[0]?.title).toContain('Local First');
|
|
||||||
expect(badges[0]?.title).toContain('Redacts Hostname, Path.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the routing badge as primary when scope is local-only even with non-restricted sensitivity', () => {
|
|
||||||
const badges = getResourcePolicyTableBadges({
|
|
||||||
sensitivity: 'sensitive',
|
|
||||||
routing: { scope: 'local-only', redact: ['alias'] },
|
|
||||||
});
|
|
||||||
expect(badges).toHaveLength(1);
|
|
||||||
expect(badges[0]?.label).toBe('Local Only');
|
|
||||||
expect(badges[0]?.title).toContain('Sensitive');
|
|
||||||
expect(badges[0]?.title).toContain('Redacts Alias.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getResourcePolicyGovernedSummary — branch coverage', () => {
|
describe('getResourcePolicyGovernedSummary — branch coverage', () => {
|
||||||
it('returns empty string when no resource is supplied', () => {
|
it('returns empty string when no resource is supplied', () => {
|
||||||
expect(getResourcePolicyGovernedSummary(undefined)).toBe('');
|
expect(getResourcePolicyGovernedSummary(undefined)).toBe('');
|
||||||
@@ -369,52 +315,3 @@ describe('getResourcePolicyDisplayLabel — branch coverage', () => {
|
|||||||
).toBe('storage array (degraded)');
|
).toBe('storage array (degraded)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('shouldShowResourceAlternateName — branch coverage', () => {
|
|
||||||
it('returns false when no resource is supplied', () => {
|
|
||||||
expect(shouldShowResourceAlternateName(undefined)).toBe(false);
|
|
||||||
expect(shouldShowResourceAlternateName(null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when displayName is absent', () => {
|
|
||||||
expect(shouldShowResourceAlternateName(asResource({ name: 'host-1' }))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when name is absent', () => {
|
|
||||||
expect(shouldShowResourceAlternateName(asResource({ displayName: 'Host One' }))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a whitespace-only displayName as present (guard does not trim) and thus differing from name', () => {
|
|
||||||
// The guard `!resource?.displayName` does not trim, so ' ' is truthy and
|
|
||||||
// passes; then ''.toLowerCase() !== 'host-1' -> true.
|
|
||||||
expect(shouldShowResourceAlternateName({ name: 'host-1', displayName: ' ' })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when the policy requires governed handling', () => {
|
|
||||||
expect(
|
|
||||||
shouldShowResourceAlternateName({
|
|
||||||
name: 'host-1',
|
|
||||||
displayName: 'Host One',
|
|
||||||
policy: governedPolicy(),
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when displayName and name match case-insensitively after trimming', () => {
|
|
||||||
expect(
|
|
||||||
shouldShowResourceAlternateName({
|
|
||||||
name: 'HOST-1',
|
|
||||||
displayName: ' host-1 ',
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns true when displayName and name differ', () => {
|
|
||||||
expect(
|
|
||||||
shouldShowResourceAlternateName({
|
|
||||||
name: 'host-1',
|
|
||||||
displayName: 'Primary Node',
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import {
|
|||||||
RESOURCE_POLICY_REDACTION_ORDER,
|
RESOURCE_POLICY_REDACTION_ORDER,
|
||||||
RESOURCE_POLICY_ROUTING_ORDER,
|
RESOURCE_POLICY_ROUTING_ORDER,
|
||||||
RESOURCE_POLICY_SENSITIVITY_ORDER,
|
RESOURCE_POLICY_SENSITIVITY_ORDER,
|
||||||
hasBlockingResourcePolicyPosture,
|
|
||||||
hasDefaultResourcePolicyPosture,
|
hasDefaultResourcePolicyPosture,
|
||||||
getResourcePolicyTableBadges,
|
|
||||||
getResourcePolicyGovernedSummary,
|
getResourcePolicyGovernedSummary,
|
||||||
getResourcePolicyDisplayLabel,
|
getResourcePolicyDisplayLabel,
|
||||||
getResourcePolicyRedactionSummaries,
|
getResourcePolicyRedactionSummaries,
|
||||||
@@ -16,7 +14,6 @@ import {
|
|||||||
getResourceRedactionHintLabel,
|
getResourceRedactionHintLabel,
|
||||||
getResourceRoutingScopeLabel,
|
getResourceRoutingScopeLabel,
|
||||||
getResourceSensitivityLabel,
|
getResourceSensitivityLabel,
|
||||||
shouldShowResourceAlternateName,
|
|
||||||
} from '@/utils/resourcePolicyPresentation';
|
} from '@/utils/resourcePolicyPresentation';
|
||||||
|
|
||||||
describe('resourcePolicyPresentation utils', () => {
|
describe('resourcePolicyPresentation utils', () => {
|
||||||
@@ -38,7 +35,7 @@ describe('resourcePolicyPresentation utils', () => {
|
|||||||
).toEqual(['Hostname', 'IP Address']);
|
).toEqual(['Hostname', 'IP Address']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps non-blocking policy posture out of table rows', () => {
|
it('recognizes only the canonical default policy posture', () => {
|
||||||
expect(
|
expect(
|
||||||
hasDefaultResourcePolicyPosture({
|
hasDefaultResourcePolicyPosture({
|
||||||
sensitivity: 'internal',
|
sensitivity: 'internal',
|
||||||
@@ -48,43 +45,6 @@ describe('resourcePolicyPresentation utils', () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
expect(
|
|
||||||
getResourcePolicyTableBadges({
|
|
||||||
sensitivity: 'internal',
|
|
||||||
routing: {
|
|
||||||
scope: 'cloud-summary',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual([]);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
hasBlockingResourcePolicyPosture({
|
|
||||||
sensitivity: 'sensitive',
|
|
||||||
routing: {
|
|
||||||
scope: 'local-first',
|
|
||||||
redact: ['hostname'],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
getResourcePolicyTableBadges({
|
|
||||||
sensitivity: 'sensitive',
|
|
||||||
routing: {
|
|
||||||
scope: 'local-first',
|
|
||||||
redact: ['hostname'],
|
|
||||||
},
|
|
||||||
}).map((badge) => badge.label),
|
|
||||||
).toEqual([]);
|
|
||||||
expect(
|
|
||||||
getResourcePolicyTableBadges({
|
|
||||||
sensitivity: 'internal',
|
|
||||||
routing: {
|
|
||||||
scope: 'cloud-summary',
|
|
||||||
redact: ['hostname'],
|
|
||||||
},
|
|
||||||
}).map((badge) => badge.label),
|
|
||||||
).toEqual([]);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
hasDefaultResourcePolicyPosture({
|
hasDefaultResourcePolicyPosture({
|
||||||
sensitivity: 'sensitive',
|
sensitivity: 'sensitive',
|
||||||
@@ -96,30 +56,6 @@ describe('resourcePolicyPresentation utils', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces blocking local-only policy posture in table rows', () => {
|
|
||||||
expect(
|
|
||||||
hasBlockingResourcePolicyPosture({
|
|
||||||
sensitivity: 'restricted',
|
|
||||||
routing: {
|
|
||||||
scope: 'local-only',
|
|
||||||
redact: ['hostname'],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
const badges = getResourcePolicyTableBadges({
|
|
||||||
sensitivity: 'restricted',
|
|
||||||
routing: {
|
|
||||||
scope: 'local-only',
|
|
||||||
redact: ['hostname'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(badges.map((badge) => badge.label)).toEqual(['Local Only']);
|
|
||||||
expect(badges[0]?.title).toContain('Restricted');
|
|
||||||
expect(badges[0]?.title).toContain('Redacts Hostname');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses concise governed labels for redacted resources', () => {
|
it('uses concise governed labels for redacted resources', () => {
|
||||||
expect(
|
expect(
|
||||||
getResourcePolicyDisplayLabel({
|
getResourcePolicyDisplayLabel({
|
||||||
@@ -187,29 +123,6 @@ describe('resourcePolicyPresentation utils', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides raw alternate names when policy requires governed handling', () => {
|
|
||||||
expect(
|
|
||||||
shouldShowResourceAlternateName({
|
|
||||||
name: 'sensitive-host',
|
|
||||||
displayName: 'Sensitive Host',
|
|
||||||
policy: {
|
|
||||||
sensitivity: 'restricted',
|
|
||||||
routing: {
|
|
||||||
scope: 'local-only',
|
|
||||||
redact: ['hostname'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
shouldShowResourceAlternateName({
|
|
||||||
name: 'host-1',
|
|
||||||
displayName: 'Host 1',
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats canonical policy count summaries', () => {
|
it('formats canonical policy count summaries', () => {
|
||||||
expect(
|
expect(
|
||||||
getResourcePolicySensitivitySummaries({
|
getResourcePolicySensitivitySummaries({
|
||||||
|
|||||||
@@ -116,39 +116,6 @@ export const hasDefaultResourcePolicyPosture = (policy?: ResourcePolicy): boolea
|
|||||||
(policy.routing.redact?.length ?? 0) === 0,
|
(policy.routing.redact?.length ?? 0) === 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const hasBlockingResourcePolicyPosture = (policy?: ResourcePolicy): boolean =>
|
|
||||||
Boolean(policy && (policy.sensitivity === 'restricted' || policy.routing.scope === 'local-only'));
|
|
||||||
|
|
||||||
export const getResourcePolicyTableBadges = (
|
|
||||||
policy?: ResourcePolicy,
|
|
||||||
): PolicyBadgePresentation[] => {
|
|
||||||
if (!policy) return [];
|
|
||||||
|
|
||||||
if (!hasBlockingResourcePolicyPosture(policy)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const sensitivity = sensitivityPresentation[policy.sensitivity];
|
|
||||||
const routing = routingPresentation[policy.routing.scope];
|
|
||||||
const redactions = getResourcePolicyRedactionLabels(policy);
|
|
||||||
const primary = policy.routing.scope === 'local-only' ? routing : sensitivity;
|
|
||||||
const redactionTitle = redactions.length > 0 ? `Redacts ${redactions.join(', ')}.` : undefined;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: primary.label,
|
|
||||||
className: primary.className,
|
|
||||||
title: [
|
|
||||||
`${sensitivity.label}: ${sensitivity.title}`,
|
|
||||||
`${routing.label}: ${routing.title}`,
|
|
||||||
redactionTitle,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' '),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getResourceSensitivityLabel = (sensitivity?: ResourceSensitivity): string =>
|
export const getResourceSensitivityLabel = (sensitivity?: ResourceSensitivity): string =>
|
||||||
sensitivity ? sensitivityPresentation[sensitivity].label : 'Unclassified';
|
sensitivity ? sensitivityPresentation[sensitivity].label : 'Unclassified';
|
||||||
|
|
||||||
@@ -263,15 +230,3 @@ export const getResourcePolicyDisplayLabel = (
|
|||||||
|
|
||||||
return resource.displayName?.trim() || resource.name?.trim() || '';
|
return resource.displayName?.trim() || resource.name?.trim() || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldShowResourceAlternateName = (
|
|
||||||
resource?: ResourcePolicyDisplayResource | null,
|
|
||||||
): boolean => {
|
|
||||||
if (!resource?.displayName || !resource.name) return false;
|
|
||||||
|
|
||||||
if (requiresGovernedResourceDisplay(resource.policy)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resource.displayName.trim().toLowerCase() !== resource.name.trim().toLowerCase();
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user