@skyhook-io/k8s-ui 1.18.0 → 1.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +342 -83
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +21 -8
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +158 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { dedupeResourceRefs, omitResourceRefs } from '../../utils/resource-refs'
|
|
1
|
+
import { dedupeResourceRefs, omitResourceRefs, resourceRefKey } from '../../utils/resource-refs'
|
|
2
|
+
import { gapReasonText } from '../../utils/read-actions'
|
|
2
3
|
import { customResourceDefinitionRef, getCRDDefinitionsSnapshot, subscribeCRDDefinitions } from '../../utils/navigation'
|
|
3
4
|
import { Disclosure } from './Disclosure'
|
|
4
5
|
import { createContext, useContext, useState, useSyncExternalStore } from 'react'
|
|
@@ -295,9 +296,9 @@ function isConditionHealthy(cond: { type?: string; status?: string }): boolean {
|
|
|
295
296
|
return inverted ? cond.status === 'False' : cond.status === 'True'
|
|
296
297
|
}
|
|
297
298
|
|
|
298
|
-
export type ConditionTone = 'ok' | 'warning' | 'fail' | 'unknown'
|
|
299
|
+
export type ConditionTone = 'ok' | 'warning' | 'fail' | 'unknown' | 'neutral'
|
|
299
300
|
|
|
300
|
-
export function defaultConditionTone(cond: { type?: string; status?: string }): ConditionTone {
|
|
301
|
+
export function defaultConditionTone(cond: { type?: string; status?: string }): Exclude<ConditionTone, 'neutral'> {
|
|
301
302
|
if (cond.status !== 'True' && cond.status !== 'False') return 'unknown'
|
|
302
303
|
return isConditionHealthy(cond) ? 'ok' : 'fail'
|
|
303
304
|
}
|
|
@@ -342,6 +343,7 @@ export function ConditionsSection({
|
|
|
342
343
|
const isOk = tone === 'ok'
|
|
343
344
|
const isWarning = tone === 'warning'
|
|
344
345
|
const isFail = tone === 'fail'
|
|
346
|
+
const isNeutral = tone === 'neutral'
|
|
345
347
|
return (
|
|
346
348
|
<div key={cond.type} className={clsx(
|
|
347
349
|
'flex items-start py-1.5 pr-1 text-sm relative',
|
|
@@ -355,12 +357,14 @@ export function ConditionsSection({
|
|
|
355
357
|
{/* Timeline dot */}
|
|
356
358
|
<span className={clsx(
|
|
357
359
|
'w-3 h-3 rounded-full flex items-center justify-center shrink-0 mt-1 z-10 ring-2 ring-theme-surface',
|
|
358
|
-
|
|
360
|
+
isNeutral ? 'bg-theme-elevated text-theme-text-tertiary'
|
|
361
|
+
: isOk ? 'bg-emerald-500/20 text-emerald-500 dark:bg-emerald-500/30'
|
|
359
362
|
: tone === 'unknown' ? 'bg-gray-400/20 text-gray-400 dark:bg-gray-400/30'
|
|
360
363
|
: isWarning ? 'bg-amber-500/25 text-amber-600 dark:text-amber-400 dark:bg-amber-500/35'
|
|
361
364
|
: 'bg-red-500/25 text-red-500 dark:bg-red-500/35'
|
|
362
365
|
)}>
|
|
363
|
-
{
|
|
366
|
+
{isNeutral ? <Minus className="w-2 h-2" strokeWidth={4} />
|
|
367
|
+
: isOk ? <Check className="w-2 h-2" strokeWidth={4} />
|
|
364
368
|
: tone === 'unknown' ? <Minus className="w-2 h-2" strokeWidth={4} />
|
|
365
369
|
: isWarning ? <AlertTriangle className="w-2 h-2" strokeWidth={4} />
|
|
366
370
|
: <X className="w-2 h-2" strokeWidth={4} />}
|
|
@@ -369,7 +373,8 @@ export function ConditionsSection({
|
|
|
369
373
|
<div className="min-w-0 flex-1 pl-2">
|
|
370
374
|
<span className={clsx(
|
|
371
375
|
'font-medium text-[13px]',
|
|
372
|
-
|
|
376
|
+
isNeutral ? 'text-theme-text-secondary'
|
|
377
|
+
: isOk ? 'text-theme-text-primary'
|
|
373
378
|
: tone === 'unknown' ? 'text-theme-text-secondary'
|
|
374
379
|
: isWarning ? 'text-amber-600 dark:text-amber-400'
|
|
375
380
|
: 'text-red-600 dark:text-red-400'
|
|
@@ -415,7 +420,7 @@ interface AlertBannerProps {
|
|
|
415
420
|
icon?: React.ComponentType<{ className?: string }>
|
|
416
421
|
title: string
|
|
417
422
|
message?: React.ReactNode
|
|
418
|
-
items?:
|
|
423
|
+
items?: React.ReactNode[]
|
|
419
424
|
children?: React.ReactNode
|
|
420
425
|
/** A control at the end of the title line, e.g. a link to where the problem is handled. */
|
|
421
426
|
action?: React.ReactNode
|
|
@@ -815,24 +820,57 @@ export type CopyHandler = (text: string, key: string) => void
|
|
|
815
820
|
// RELATED RESOURCES SECTION
|
|
816
821
|
// ============================================================================
|
|
817
822
|
|
|
818
|
-
import type { TimelineEvent, Relationships, ResourceRef } from '../../types'
|
|
823
|
+
import type { TimelineEvent, Relationships, ResourceRef, OwnershipCoverage, ManagerAnswer } from '../../types'
|
|
819
824
|
import { isChangeEvent, isK8sEvent } from '../../types'
|
|
820
|
-
import { Link } from 'lucide-react'
|
|
825
|
+
import { Info, Link } from 'lucide-react'
|
|
821
826
|
import { formatRelativeAgeTime } from '../../utils/format'
|
|
822
827
|
|
|
823
828
|
interface RelatedResourcesSectionProps {
|
|
824
829
|
relationships: Relationships | undefined
|
|
825
830
|
onNavigate?: (ref: ResourceRef) => void
|
|
831
|
+
/** The URL plural of the object shown (e.g. `deployments`). */
|
|
832
|
+
kind?: string
|
|
833
|
+
/** The manager the resource's tracking metadata names, when every manager's
|
|
834
|
+
* list was read, that one's doesn't list the resource and another's does. */
|
|
835
|
+
trackedBy?: string
|
|
826
836
|
}
|
|
827
837
|
|
|
828
|
-
|
|
838
|
+
// Kinds whose objects usually own others. An empty Children list for one of
|
|
839
|
+
// these is surprising, so it says it covered only the kinds Radar checked;
|
|
840
|
+
// for any other kind an empty list stays hidden, or nearly every leaf object
|
|
841
|
+
// would carry the note.
|
|
842
|
+
const KINDS_WITH_CHILDREN = new Set(['deployments', 'replicasets', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs', 'services', 'rollouts'])
|
|
843
|
+
|
|
844
|
+
export function RelatedResourcesSection({ relationships, onNavigate, kind, trackedBy }: RelatedResourcesSectionProps) {
|
|
829
845
|
if (!relationships) return null
|
|
830
846
|
|
|
831
847
|
const configRefs = omitResourceRefs(relationships.configRefs ?? [], relationships.dependencies ?? [])
|
|
832
848
|
const consumers = omitResourceRefs(relationships.consumers ?? [], relationships.dependents ?? [])
|
|
849
|
+
const alsoOwnedBy = omitResourceRefs(relationships.owners ?? [], relationships.owner ? [relationships.owner] : [])
|
|
850
|
+
const managesNote = membersNote(relationships.ownership?.manages, (relationships.manages?.length ?? 0) > 0)
|
|
851
|
+
const managers = dedupeResourceRefs(relationships.managers ?? [])
|
|
852
|
+
const managedByNote = managersNote(relationships.ownership, managers.length, trackedBy)
|
|
853
|
+
const unanswered = ownershipUnanswered(relationships.ownership)
|
|
854
|
+
const several = !relationships.owner && relationships.ownership?.primaryOwnerState === 'several'
|
|
855
|
+
const ownerMissing = relationships.owner || several ? undefined : ownerNote(relationships.ownership)
|
|
856
|
+
let childrenCoverage = childrenNote(relationships.ownership)
|
|
857
|
+
const noChildren = !relationships.children?.length && !relationships.olderRevisions?.length
|
|
858
|
+
if (noChildren && childrenCoverage?.hint && !childrenCoverage.text && kind && KINDS_WITH_CHILDREN.has(kind)) {
|
|
859
|
+
childrenCoverage = { ...childrenCoverage, text: 'No children among the kinds Radar checked.' }
|
|
860
|
+
}
|
|
833
861
|
|
|
834
862
|
const hasRelationships =
|
|
835
863
|
relationships.owner ||
|
|
864
|
+
unanswered ||
|
|
865
|
+
ownerMissing ||
|
|
866
|
+
several ||
|
|
867
|
+
childrenCoverage?.text ||
|
|
868
|
+
(relationships.olderRevisions && relationships.olderRevisions.length > 0) ||
|
|
869
|
+
alsoOwnedBy.length > 0 ||
|
|
870
|
+
managers.length > 0 ||
|
|
871
|
+
managedByNote.text ||
|
|
872
|
+
(relationships.manages && relationships.manages.length > 0) ||
|
|
873
|
+
managesNote ||
|
|
836
874
|
relationships.deployment ||
|
|
837
875
|
relationships.node ||
|
|
838
876
|
(relationships.children && relationships.children.length > 0) ||
|
|
@@ -864,8 +902,20 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
|
|
|
864
902
|
return (
|
|
865
903
|
<Section title="Related Resources" icon={Link} defaultExpanded>
|
|
866
904
|
<div className="space-y-3">
|
|
867
|
-
{
|
|
868
|
-
|
|
905
|
+
{unanswered && <div className="text-xs text-theme-text-tertiary">{unanswered}</div>}
|
|
906
|
+
{(relationships.owner || ownerMissing) && (
|
|
907
|
+
<RelationshipGroup label="Owner" refs={relationships.owner ? [relationships.owner] : []} onNavigate={onNavigate} note={ownerMissing} />
|
|
908
|
+
)}
|
|
909
|
+
{(alsoOwnedBy.length > 0 || several) && (
|
|
910
|
+
<RelationshipGroup
|
|
911
|
+
label={relationships.owner || ownerMissing ? 'Also Owned By' : 'Owners'}
|
|
912
|
+
refs={dedupeResourceRefs(alsoOwnedBy)}
|
|
913
|
+
onNavigate={onNavigate}
|
|
914
|
+
note={several ? { text: alsoOwnedBy.length > 0 ? 'None of them is its controller.' : 'None of the owners it names can be shown.' } : undefined}
|
|
915
|
+
/>
|
|
916
|
+
)}
|
|
917
|
+
{(managers.length > 0 || managedByNote.text) && (
|
|
918
|
+
<RelationshipGroup label={managersListOnly(relationships.ownership?.manager, managers) ? 'Listed By' : 'Managed By'} refs={managers} onNavigate={onNavigate} note={managedByNote} />
|
|
869
919
|
)}
|
|
870
920
|
{relationships.deployment && (
|
|
871
921
|
<RelationshipGroup label="Deployment" refs={[relationships.deployment]} onNavigate={onNavigate} />
|
|
@@ -873,8 +923,18 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
|
|
|
873
923
|
{relationships.node && (
|
|
874
924
|
<RelationshipGroup label="Node" refs={[relationships.node]} onNavigate={onNavigate} />
|
|
875
925
|
)}
|
|
876
|
-
{relationships.children && relationships.children.length > 0 && (
|
|
877
|
-
<RelationshipGroup
|
|
926
|
+
{((relationships.children && relationships.children.length > 0) || childrenCoverage?.text || (relationships.olderRevisions && relationships.olderRevisions.length > 0)) && (
|
|
927
|
+
<RelationshipGroup
|
|
928
|
+
label="Children"
|
|
929
|
+
refs={dedupeResourceRefs(relationships.children ?? [])}
|
|
930
|
+
onNavigate={onNavigate}
|
|
931
|
+
note={childrenCoverage}
|
|
932
|
+
more={olderRevisions(relationships.olderRevisions)}
|
|
933
|
+
capped={relationships.ownership?.children?.truncated}
|
|
934
|
+
/>
|
|
935
|
+
)}
|
|
936
|
+
{((relationships.manages && relationships.manages.length > 0) || managesNote) && (
|
|
937
|
+
<RelationshipGroup label="Manages" refs={dedupeResourceRefs(relationships.manages ?? [])} onNavigate={onNavigate} note={managesNote} capped={relationships.ownership?.manages?.truncated} />
|
|
878
938
|
)}
|
|
879
939
|
{relationships.services && relationships.services.length > 0 && (
|
|
880
940
|
<RelationshipGroup label="Services" refs={dedupeResourceRefs(relationships.services)} onNavigate={onNavigate} />
|
|
@@ -947,23 +1007,220 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
|
|
|
947
1007
|
)
|
|
948
1008
|
}
|
|
949
1009
|
|
|
1010
|
+
// ownershipUnanswered says why Owner and Children couldn't be answered at all.
|
|
1011
|
+
function ownershipUnanswered(coverage: OwnershipCoverage | undefined): string | undefined {
|
|
1012
|
+
const reason = coverage?.unavailableReason
|
|
1013
|
+
if (!reason) return undefined
|
|
1014
|
+
if (reason === 'user_forbidden') return "Owner and children aren't shown: your access lets you open this object, but not list its kind here."
|
|
1015
|
+
return `Owner and children couldn't be read: ${gapReasonText(reason)}.`
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// ownerNote says why no owner is named, when there is an owner reference.
|
|
1019
|
+
function ownerNote(coverage: OwnershipCoverage | undefined): RelationshipNote | undefined {
|
|
1020
|
+
const ref = coverage?.ownerRef
|
|
1021
|
+
const named = ref?.kind && ref.name ? `${ref.kind} ${ref.name}` : 'the owner it names'
|
|
1022
|
+
switch (coverage?.primaryOwnerState) {
|
|
1023
|
+
case 'gone':
|
|
1024
|
+
return { text: `${capitalize(named)} no longer exists.` }
|
|
1025
|
+
case 'replaced':
|
|
1026
|
+
return { text: `${capitalize(named)} was deleted and recreated; this object names the earlier one.` }
|
|
1027
|
+
case 'unknown':
|
|
1028
|
+
return { text: coverage?.ownerReason ? `Couldn't check ${named} (${gapReasonText(coverage.ownerReason)}).` : `Couldn't check ${named}.` }
|
|
1029
|
+
case 'withheld':
|
|
1030
|
+
return { text: 'Not visible to you.' }
|
|
1031
|
+
case 'unresolvable':
|
|
1032
|
+
return { text: "Its owner reference can't be resolved in this cluster." }
|
|
1033
|
+
case 'excluded':
|
|
1034
|
+
return { text: "Its owner reference names an Event, which Radar doesn't follow as an owner." }
|
|
1035
|
+
}
|
|
1036
|
+
return undefined
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// childrenNote says what a Children list may be missing. Kinds Radar doesn't
|
|
1040
|
+
// collect are a hint, since most lists miss a few; kinds it couldn't check
|
|
1041
|
+
// for any other reason, a list cut short, and places the viewer may not read
|
|
1042
|
+
// are said outright.
|
|
1043
|
+
function childrenNote(coverage: OwnershipCoverage | undefined): RelationshipNote | undefined {
|
|
1044
|
+
const children = coverage?.children
|
|
1045
|
+
if (!children) return undefined
|
|
1046
|
+
const uncollected = new Set<string>()
|
|
1047
|
+
let someNamespaces = false
|
|
1048
|
+
let undiscovered = false
|
|
1049
|
+
const pressing = new Map<string, string>()
|
|
1050
|
+
for (const g of children.notChecked ?? []) {
|
|
1051
|
+
if (!g.kind) undiscovered = true
|
|
1052
|
+
else if (g.reason === 'radar_kind_not_collected') uncollected.add(g.kind)
|
|
1053
|
+
else if (g.reason === 'radar_namespace_not_collected') someNamespaces = true
|
|
1054
|
+
else if (!pressing.has(g.kind)) pressing.set(g.kind, gapReasonText(g.reason))
|
|
1055
|
+
}
|
|
1056
|
+
const note: RelationshipNote = {}
|
|
1057
|
+
const hint: string[] = []
|
|
1058
|
+
if (uncollected.size > 0) hint.push(`Radar doesn't collect ${joinOr(Array.from(uncollected))} objects, so none of those are listed.`)
|
|
1059
|
+
if (someNamespaces) hint.push("Radar doesn't collect every namespace, so children in the others aren't listed.")
|
|
1060
|
+
// An API group whose discovery fails, often a metrics API with no server
|
|
1061
|
+
// behind it, leaves every answer with this gap; most such groups hold no
|
|
1062
|
+
// children, so it is a hint, not a warning.
|
|
1063
|
+
if (undiscovered) hint.push("Radar's API discovery missed some API groups, so children of their kinds aren't listed.")
|
|
1064
|
+
if (children.narrowed) hint.push('Only children you can view are listed.')
|
|
1065
|
+
if (hint.length > 0) note.hint = hint
|
|
1066
|
+
const text: string[] = []
|
|
1067
|
+
if (pressing.size > 0) text.push(`Couldn't check ${Array.from(pressing, ([kind, why]) => `${kind} (${why})`).join(', ')}.`)
|
|
1068
|
+
if (children.truncated) text.push('More children than shown.')
|
|
1069
|
+
if (text.length > 0) note.text = text.join(' ')
|
|
1070
|
+
return note.hint || note.text ? note : undefined
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function capitalize(s: string): string {
|
|
1074
|
+
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function joinOr(items: string[]): string {
|
|
1078
|
+
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// membersNote says what a GitOps manager's status records beyond the members
|
|
1082
|
+
// Manages lists; listed says whether it lists any.
|
|
1083
|
+
function membersNote(manages: OwnershipCoverage['manages'] | undefined, listed: boolean): RelationshipNote | undefined {
|
|
1084
|
+
if (!manages) return undefined
|
|
1085
|
+
if (manages.membership === 'remote') return { text: "Its destination isn't this cluster as far as Radar can tell, so its members aren't listed." }
|
|
1086
|
+
if (manages.membership === 'unreadable') {
|
|
1087
|
+
const note: RelationshipNote = {}
|
|
1088
|
+
const text: string[] = []
|
|
1089
|
+
if (manages.unavailableReason) text.push(`Couldn't read the members its status records (${gapReasonText(manages.unavailableReason)}).`)
|
|
1090
|
+
if (manages.truncated) text.push('More members than shown.')
|
|
1091
|
+
if (text.length > 0) note.text = text.join(' ')
|
|
1092
|
+
if (listed) note.hint = ["Its status has no member list Radar can read now, so these are the resources the topology links to it."]
|
|
1093
|
+
else if (!manages.unavailableReason) note.hint = ["Its status has no member list Radar can read now."]
|
|
1094
|
+
return note
|
|
1095
|
+
}
|
|
1096
|
+
const extra: string[] = []
|
|
1097
|
+
if (manages.missing) extra.push(`${manages.missing} not in the cluster`)
|
|
1098
|
+
if (manages.withheld) extra.push(`${manages.withheld} you can't view`)
|
|
1099
|
+
if (manages.unknown) extra.push(`${manages.unknown} Radar can't check`)
|
|
1100
|
+
const parts: string[] = []
|
|
1101
|
+
if (extra.length > 0) parts.push(`Its status also lists ${extra.join(', ')}.`)
|
|
1102
|
+
if (manages.truncated) parts.push('More members than shown.')
|
|
1103
|
+
if (parts.length === 0 && !listed) parts.push('Its status records no members to show.')
|
|
1104
|
+
return parts.length > 0 ? { text: parts.join(' ') } : undefined
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
export interface RelationshipNote {
|
|
1108
|
+
/** Shown under the group. */
|
|
1109
|
+
text?: string
|
|
1110
|
+
/** Lines shown on hovering an info mark beside the label. */
|
|
1111
|
+
hint?: string[]
|
|
1112
|
+
}
|
|
1113
|
+
|
|
950
1114
|
interface RelationshipGroupProps {
|
|
951
1115
|
label: string
|
|
952
1116
|
refs: ResourceRef[]
|
|
953
1117
|
onNavigate?: (ref: ResourceRef) => void
|
|
1118
|
+
note?: RelationshipNote
|
|
1119
|
+
/** Refs shown only on request, such as a Deployment's old ReplicaSets. */
|
|
1120
|
+
more?: MoreRefs
|
|
1121
|
+
/** The server cut the list short, so refs is not every one. */
|
|
1122
|
+
capped?: boolean
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
interface MoreRefs {
|
|
1126
|
+
refs: ResourceRef[]
|
|
1127
|
+
/** What the refs are, as "older ReplicaSets" reads after a count. */
|
|
1128
|
+
label: string
|
|
1129
|
+
/** Why they're kept apart, shown on hovering the toggle. */
|
|
1130
|
+
hint: string
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// olderRevisions are the ReplicaSets the server keeps out of Children because
|
|
1134
|
+
// they only hold rollback history: listed there, ten of them would bury the
|
|
1135
|
+
// ones running Pods.
|
|
1136
|
+
function olderRevisions(refs: ResourceRef[] | undefined): MoreRefs | undefined {
|
|
1137
|
+
if (!refs || refs.length === 0) return undefined
|
|
1138
|
+
const unique = dedupeResourceRefs(refs)
|
|
1139
|
+
return {
|
|
1140
|
+
refs: unique,
|
|
1141
|
+
label: unique.length === 1 ? 'older ReplicaSet' : 'older ReplicaSets',
|
|
1142
|
+
hint: 'Earlier revisions kept for rollback. They run no Pods.',
|
|
1143
|
+
}
|
|
954
1144
|
}
|
|
955
1145
|
|
|
956
1146
|
const RELATIONSHIP_TRUNCATE_LIMIT = 10
|
|
957
1147
|
|
|
958
|
-
|
|
1148
|
+
// Managed By sits next to Owner and reads like it, so it says how they differ
|
|
1149
|
+
// and which direction its answer comes from.
|
|
1150
|
+
const MANAGED_BY_HINT = [
|
|
1151
|
+
'Argo CD Applications or Flux objects whose status lists this resource, and other controllers that manage it without an owner reference.',
|
|
1152
|
+
"Kubernetes' garbage collector follows owner references, not these links.",
|
|
1153
|
+
]
|
|
1154
|
+
|
|
1155
|
+
// managersListOnly: every manager shown is one whose list names the resource
|
|
1156
|
+
// while its metadata doesn't confirm it, which the chip calls Listed by.
|
|
1157
|
+
function managersListOnly(answer: ManagerAnswer | undefined, managers: ResourceRef[]): boolean {
|
|
1158
|
+
if (!answer || answer.via || !['listed', 'shared', 'disagree'].includes(answer.state) || !managers.length) return false
|
|
1159
|
+
const recorders = new Set((answer.direct.recorders ?? []).map(resourceRefKey))
|
|
1160
|
+
return managers.every(m => recorders.has(resourceRefKey(m)))
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// managersNote explains Managed By: where it comes from, more than one
|
|
1164
|
+
// manager, and the managers the search couldn't settle. With none listed, a
|
|
1165
|
+
// remote list naming the resource or a manager kind Radar couldn't search is
|
|
1166
|
+
// said outright, so an empty group doesn't read as "no GitOps manager". An
|
|
1167
|
+
// unreadable list stays in the hint: it may name any resource, so it would
|
|
1168
|
+
// otherwise show beside every one, and the controllers stamp tracking
|
|
1169
|
+
// metadata on what they apply, through which the topology names such a
|
|
1170
|
+
// manager anyway.
|
|
1171
|
+
function managersNote(coverage: OwnershipCoverage | undefined, listed: number, trackedBy?: string): RelationshipNote {
|
|
1172
|
+
const hint = [...MANAGED_BY_HINT]
|
|
1173
|
+
const open: string[] = []
|
|
1174
|
+
const coverageOf = coverage?.managers
|
|
1175
|
+
if (coverageOf?.unreadable) {
|
|
1176
|
+
const n = coverageOf.unreadable
|
|
1177
|
+
hint.push(n === 1 ? "1 manager's member list can't be read, so it may also list this resource." : `${n} managers' member lists can't be read, so they may also list this resource.`)
|
|
1178
|
+
}
|
|
1179
|
+
if (coverageOf?.remote) {
|
|
1180
|
+
const n = coverageOf.remote
|
|
1181
|
+
open.push(`${n} ${n === 1 ? 'manager lists' : 'managers list'} it for a cluster Radar can't confirm is this one`)
|
|
1182
|
+
}
|
|
1183
|
+
const pressing = new Map<string, string>()
|
|
1184
|
+
for (const g of coverageOf?.notChecked ?? []) {
|
|
1185
|
+
// An unserved manager kind under incomplete discovery reads as read_error
|
|
1186
|
+
// on every resource, like discovery's own gap.
|
|
1187
|
+
if (!g.kind) hint.push("Radar's API discovery missed some API groups, so managers of their kinds aren't checked.")
|
|
1188
|
+
else if (g.reason === 'read_error') hint.push(`Radar couldn't search ${g.kind} objects, so those managers aren't checked.`)
|
|
1189
|
+
else if (g.reason === 'radar_kind_not_collected') hint.push(`Radar doesn't collect ${g.kind} objects, so those managers aren't checked.`)
|
|
1190
|
+
else if (!pressing.has(g.kind)) pressing.set(g.kind, gapReasonText(g.reason))
|
|
1191
|
+
}
|
|
1192
|
+
if (pressing.size > 0) open.push(`couldn't check ${Array.from(pressing, ([kind, why]) => `${kind} (${why})`).join(', ')}`)
|
|
1193
|
+
const text: string[] = []
|
|
1194
|
+
if (trackedBy) text.push(`Its tracking label names ${trackedBy}, whose status doesn't list it.`)
|
|
1195
|
+
if (open.length > 0) {
|
|
1196
|
+
const sentence = `${capitalize(open.join('; '))}.`
|
|
1197
|
+
if (listed === 0) text.push(sentence)
|
|
1198
|
+
else hint.push(sentence)
|
|
1199
|
+
}
|
|
1200
|
+
if (listed > 1) text.push('More than one manager lists or links to it.')
|
|
1201
|
+
const note: RelationshipNote = { hint: [...new Set(hint)] }
|
|
1202
|
+
if (text.length > 0) note.text = text.join(' ')
|
|
1203
|
+
return note
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
export function RelationshipGroup({ label, refs, onNavigate, note, more, capped }: RelationshipGroupProps) {
|
|
959
1207
|
const [showAll, setShowAll] = useState(false)
|
|
960
|
-
if (!refs || refs.length === 0) return null
|
|
1208
|
+
if ((!refs || refs.length === 0) && !note && !more) return null
|
|
961
1209
|
const truncated = !showAll && refs.length > RELATIONSHIP_TRUNCATE_LIMIT
|
|
962
1210
|
const visibleRefs = truncated ? refs.slice(0, RELATIONSHIP_TRUNCATE_LIMIT) : refs
|
|
963
1211
|
|
|
964
1212
|
return (
|
|
965
1213
|
<div>
|
|
966
|
-
<div className="text-xs text-theme-text-tertiary mb-1
|
|
1214
|
+
<div className="text-xs text-theme-text-tertiary mb-1 flex items-center gap-1">
|
|
1215
|
+
<span>{label}{refs.length > 1 ? ` (${refs.length}${capped ? '+' : ''})` : ''}</span>
|
|
1216
|
+
{note?.hint && (
|
|
1217
|
+
<Tooltip content={<div className="space-y-0.5">{note.hint.map((line) => <div key={line}>{line}</div>)}</div>} delay={150} className="max-h-64 overflow-y-auto">
|
|
1218
|
+
<button type="button" className="inline-flex rounded text-theme-text-tertiary hover:text-theme-text-secondary" aria-label={note.hint.join(' ')}>
|
|
1219
|
+
<Info className="w-3 h-3" aria-hidden="true" />
|
|
1220
|
+
</button>
|
|
1221
|
+
</Tooltip>
|
|
1222
|
+
)}
|
|
1223
|
+
</div>
|
|
967
1224
|
<div className="flex flex-wrap gap-1">
|
|
968
1225
|
{visibleRefs.map((resourceRef, i) => (
|
|
969
1226
|
<ResourceRefBadge key={`${resourceRef.kind}-${resourceRef.namespace}-${resourceRef.name}-${i}`} resourceRef={resourceRef} onClick={onNavigate} />
|
|
@@ -973,10 +1230,37 @@ export function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroup
|
|
|
973
1230
|
onClick={() => setShowAll(true)}
|
|
974
1231
|
className="px-2 py-0.5 text-xs rounded border border-theme-border text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors"
|
|
975
1232
|
>
|
|
976
|
-
Show all {refs.length}
|
|
1233
|
+
{capped ? `Show the ${refs.length} listed` : `Show all ${refs.length}`}
|
|
977
1234
|
</button>
|
|
978
1235
|
)}
|
|
979
1236
|
</div>
|
|
1237
|
+
{more && <MoreRefsToggle more={more} onNavigate={onNavigate} />}
|
|
1238
|
+
{note?.text && <div className="mt-1 text-xs text-theme-text-tertiary">{note.text}</div>}
|
|
1239
|
+
</div>
|
|
1240
|
+
)
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function MoreRefsToggle({ more, onNavigate }: { more: MoreRefs; onNavigate?: (ref: ResourceRef) => void }) {
|
|
1244
|
+
const [open, setOpen] = useState(false)
|
|
1245
|
+
const { panelId, buttonProps } = useDisclosure(open)
|
|
1246
|
+
return (
|
|
1247
|
+
<div className="mt-1">
|
|
1248
|
+
<button
|
|
1249
|
+
{...buttonProps}
|
|
1250
|
+
onClick={() => setOpen(!open)}
|
|
1251
|
+
title={more.hint}
|
|
1252
|
+
className="flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
|
|
1253
|
+
>
|
|
1254
|
+
<CollapseChevron open={open} className="w-3 h-3" />
|
|
1255
|
+
{open ? `Hide ${more.label}` : `Show ${more.refs.length} ${more.label}`}
|
|
1256
|
+
</button>
|
|
1257
|
+
<Collapse open={open} id={panelId} mountLazily>
|
|
1258
|
+
<div className="flex flex-wrap gap-1 mt-1">
|
|
1259
|
+
{more.refs.map((resourceRef, i) => (
|
|
1260
|
+
<ResourceRefBadge key={`${resourceRef.kind}-${resourceRef.namespace}-${resourceRef.name}-${i}`} resourceRef={resourceRef} onClick={onNavigate} />
|
|
1261
|
+
))}
|
|
1262
|
+
</div>
|
|
1263
|
+
</Collapse>
|
|
980
1264
|
</div>
|
|
981
1265
|
)
|
|
982
1266
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { renderToString } from 'react-dom/server'
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
6
|
+
import { RelatedResourcesSection } from './drawer-components'
|
|
7
|
+
import type { Relationships } from '../../types'
|
|
8
|
+
|
|
9
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
10
|
+
|
|
11
|
+
const rs = (name: string) => ({ group: 'apps', kind: 'ReplicaSet', namespace: 'shop', name })
|
|
12
|
+
let container: HTMLDivElement | undefined
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
container?.remove()
|
|
15
|
+
container = undefined
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function mount(relationships: Relationships) {
|
|
19
|
+
container = document.createElement('div')
|
|
20
|
+
document.body.appendChild(container)
|
|
21
|
+
const root = createRoot(container)
|
|
22
|
+
act(() => root.render(<RelatedResourcesSection relationships={relationships} />))
|
|
23
|
+
return container
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('older ReplicaSets', () => {
|
|
27
|
+
it('lists the ReplicaSets running Pods, and the old revisions only on request', () => {
|
|
28
|
+
const el = mount({ children: [rs('web-7d4f')], olderRevisions: [rs('web-5c9a'), rs('web-6b1e')] })
|
|
29
|
+
const toggle = Array.from(el.querySelectorAll('button')).find((b) => b.textContent?.includes('older ReplicaSets'))
|
|
30
|
+
expect(toggle?.textContent).toBe('Show 2 older ReplicaSets')
|
|
31
|
+
expect(toggle?.getAttribute('aria-expanded')).toBe('false')
|
|
32
|
+
expect(el.textContent).toContain('web-7d4f')
|
|
33
|
+
expect(el.textContent).not.toContain('web-5c9a')
|
|
34
|
+
act(() => toggle?.click())
|
|
35
|
+
expect(toggle?.getAttribute('aria-expanded')).toBe('true')
|
|
36
|
+
expect(toggle?.textContent).toBe('Hide older ReplicaSets')
|
|
37
|
+
expect(el.textContent).toContain('web-5c9a')
|
|
38
|
+
expect(el.textContent).toContain('web-6b1e')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('shows Children for old revisions alone, and nothing extra without them', () => {
|
|
42
|
+
expect(renderToString(<RelatedResourcesSection relationships={{ olderRevisions: [rs('web-5c9a')] }} />)).toContain('Show 1 older ReplicaSet')
|
|
43
|
+
expect(renderToString(<RelatedResourcesSection relationships={{ children: [rs('web-7d4f')] }} />)).not.toContain('older ReplicaSet')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -35,3 +35,76 @@ describe('workload staged-policy projection', () => {
|
|
|
35
35
|
expect(html).not.toContain('Network policies')
|
|
36
36
|
})
|
|
37
37
|
})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
describe('workload GitOps manager', () => {
|
|
41
|
+
const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'shop' }
|
|
42
|
+
const render = (relationships: Parameters<typeof WorkloadView>[0]['relationships']) =>
|
|
43
|
+
renderToStaticMarkup(<WorkloadView kind="deployments" namespace="demo" name="web" onBack={() => {}} resource={workload} relationships={relationships} expanded={false} />)
|
|
44
|
+
|
|
45
|
+
it('shows the header chip alone when its manager is the only one whose status lists the workload', () => {
|
|
46
|
+
const html = render({ managedBy: [app], managers: [app], ownership: { children: { complete: true } } })
|
|
47
|
+
expect(html).toContain('Managed by')
|
|
48
|
+
expect(html).not.toContain('Managed By')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('keeps Managed By when the search left managers it could not settle', () => {
|
|
52
|
+
const html = render({ managedBy: [app], managers: [app], ownership: { children: { complete: true }, managers: { remote: 1 } } })
|
|
53
|
+
expect(html).toContain('Managed By')
|
|
54
|
+
expect(html).toContain('Managed by')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps Managed By beside a chip whose manager its status does not list', () => {
|
|
58
|
+
const hub = { ...app, name: 'hub' }
|
|
59
|
+
const html = render({ managedBy: [app], managers: [hub], ownership: { children: { complete: true } } })
|
|
60
|
+
expect(html).toContain('Managed By')
|
|
61
|
+
expect(html).toContain('Tracked by')
|
|
62
|
+
expect(html).toContain("Its tracking label names Application argocd/shop, whose status doesn't list it.")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('names a manager known only by its instance label without a namespace', () => {
|
|
66
|
+
const hub = { ...app, name: 'hub' }
|
|
67
|
+
const html = render({ managedBy: [{ kind: 'Application', group: 'argoproj.io', namespace: '', name: 'shop' }], managers: [hub], ownership: { children: { complete: true } } })
|
|
68
|
+
expect(html).toContain("Its tracking label names Application shop, whose status doesn't list it.")
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('manager answer group projection', () => {
|
|
73
|
+
const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'shop' }
|
|
74
|
+
const object = { kind: 'Deployment', group: 'apps', namespace: 'demo', name: 'web' }
|
|
75
|
+
const answer: import('../../types/core').ManagerAnswer = { state: 'listed', lead: app, direct: { object, recorders: [app] } }
|
|
76
|
+
const render = (manager: typeof answer, extra: Partial<import('../../types/core').Relationships> = {}) =>
|
|
77
|
+
renderToStaticMarkup(<WorkloadView kind="deployments" namespace="demo" name="web" onBack={() => {}} resource={workload} expanded={false}
|
|
78
|
+
relationships={{ managers: [app], ownership: { children: { complete: true }, manager }, ...extra }} />)
|
|
79
|
+
// The group is titled the way the chip words the answer.
|
|
80
|
+
const group = (html: string) => html.includes('Listed By') ? 'Listed By' : html.includes('Managed By') ? 'Managed By' : null
|
|
81
|
+
|
|
82
|
+
it.each(['agree', 'listed'] as const)('hides only the exact single %s recorder', state => {
|
|
83
|
+
const html = render({ ...answer, state })
|
|
84
|
+
expect(html).toContain(state === 'agree' ? 'Managed by' : 'Listed by')
|
|
85
|
+
expect(group(html)).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
it.each(['claimed', 'unknown', 'none'] as const)('keeps Managed By for %s', state => {
|
|
88
|
+
expect(group(render({ ...answer, state }))).toBe('Managed By')
|
|
89
|
+
})
|
|
90
|
+
it.each(['disagree', 'shared'] as const)('keeps the group for %s, titled Listed By', state => {
|
|
91
|
+
expect(group(render({ ...answer, state }))).toBe('Listed By')
|
|
92
|
+
})
|
|
93
|
+
it('keeps Managed By with via', () => {
|
|
94
|
+
expect(render({ ...answer, via: { object, recorders: [app] } })).toContain('Managed By')
|
|
95
|
+
})
|
|
96
|
+
it('keeps extra non-recorded managers', () => {
|
|
97
|
+
expect(render(answer, { managers: [app, { kind: 'Certificate', group: 'cert-manager.io', namespace: 'demo', name: 'tls' }] })).toContain('Managed By')
|
|
98
|
+
})
|
|
99
|
+
it('keeps a different single manager', () => {
|
|
100
|
+
expect(render(answer, { managers: [{ ...app, name: 'other' }] })).toContain('Managed By')
|
|
101
|
+
})
|
|
102
|
+
it('keeps the coverage note', () => {
|
|
103
|
+
expect(group(render(answer, { ownership: { children: { complete: true }, manager: answer, managers: { unreadable: 1 } } }))).toBe('Listed By')
|
|
104
|
+
})
|
|
105
|
+
it('uses native Helm’s existing chip', () => {
|
|
106
|
+
const helm = { kind: 'HelmRelease', group: '', namespace: 'demo', name: 'chart' }
|
|
107
|
+
const html = render({ state: 'claimed', lead: helm, direct: { object, claims: [{ manager: helm, source: 'helm-release', check: 'not-recording' }] } })
|
|
108
|
+
expect(html).toContain('Managed by Helm')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { StrictMode, act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import type { TimelineEvent } from '../../types'
|
|
6
|
+
import { WorkloadView } from './WorkloadView'
|
|
7
|
+
|
|
8
|
+
vi.mock('../timeline/TimelineSwimlanes', () => ({ TimelineSwimlanes: () => null }))
|
|
9
|
+
|
|
10
|
+
const resource = { apiVersion: 'batch/v1', kind: 'CronJob', metadata: { name: 'nightly', namespace: 'demo', uid: 'cronjob-1' }, spec: { schedule: '0 * * * *' } }
|
|
11
|
+
const row = (id: string, minutesAgo: number): TimelineEvent => ({
|
|
12
|
+
id, uid: 'cronjob-1', kind: 'CronJob', apiVersion: 'batch/v1', namespace: 'demo', name: 'nightly',
|
|
13
|
+
source: 'k8s_event', eventType: 'Warning', reason: 'InvalidSchedule', message: `report ${id}`,
|
|
14
|
+
timestamp: new Date(Date.now() - minutesAgo * 60_000).toISOString(),
|
|
15
|
+
})
|
|
16
|
+
const focus = { id: 'target' }
|
|
17
|
+
let root: Root
|
|
18
|
+
let container: HTMLDivElement
|
|
19
|
+
const scroll = vi.fn()
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
22
|
+
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
|
23
|
+
vi.stubGlobal('CSS', { escape: (id: string) => id })
|
|
24
|
+
HTMLElement.prototype.scrollIntoView = scroll
|
|
25
|
+
scroll.mockClear()
|
|
26
|
+
container = document.createElement('div')
|
|
27
|
+
document.body.append(container)
|
|
28
|
+
root = createRoot(container)
|
|
29
|
+
})
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await act(async () => root.unmount())
|
|
32
|
+
container.remove()
|
|
33
|
+
vi.unstubAllGlobals()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
async function render(events: TimelineEvent[], options: { truncated?: boolean; loadOlder?: () => void; loadingOlder?: boolean; error?: Error; target?: { id: string } } = {}) {
|
|
37
|
+
await act(async () => root.render(<StrictMode><WorkloadView
|
|
38
|
+
kind="cronjobs" namespace="demo" name="nightly" resource={resource} onBack={() => {}}
|
|
39
|
+
expanded activeTab="timeline" allEvents={events} historyScoped timelineFocusEvent={options.target ?? focus}
|
|
40
|
+
historyTruncated={options.truncated} onLoadOlderHistory={options.loadOlder}
|
|
41
|
+
loadingOlderHistory={options.loadingOlder} olderHistoryError={options.error}
|
|
42
|
+
/></StrictMode>))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it('scrolls to the exact report even among warnings with the same reason', async () => {
|
|
46
|
+
await render([row('newest', 1), row('target', 5), row('oldest', 10)])
|
|
47
|
+
const target = container.querySelector('[data-event-id="target"]')
|
|
48
|
+
expect(target).not.toBeNull()
|
|
49
|
+
expect(target?.textContent).toContain('report target')
|
|
50
|
+
expect(target?.querySelector('.ring-2')).not.toBeNull()
|
|
51
|
+
expect(scroll).toHaveBeenCalled()
|
|
52
|
+
expect(container.textContent).not.toContain('not available')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('loads older pages until the report appears, without duplicate requests in Strict Mode', async () => {
|
|
56
|
+
const loadOlder = vi.fn()
|
|
57
|
+
await render([row('newest', 1)], { truncated: true, loadOlder })
|
|
58
|
+
expect(loadOlder).toHaveBeenCalledTimes(1)
|
|
59
|
+
expect(container.textContent).toContain('Finding the reported error')
|
|
60
|
+
await render([row('newest', 1), row('middle', 2)], { truncated: true, loadOlder })
|
|
61
|
+
expect(loadOlder).toHaveBeenCalledTimes(2)
|
|
62
|
+
await render([row('newest', 1), row('target', 5)], { truncated: true, loadOlder })
|
|
63
|
+
expect(loadOlder).toHaveBeenCalledTimes(2)
|
|
64
|
+
expect(container.querySelector('[data-event-id="target"] .ring-2')).not.toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('explains an absent report and stops when history is exhausted', async () => {
|
|
68
|
+
const loadOlder = vi.fn()
|
|
69
|
+
await render([row('newest', 1)], { loadOlder })
|
|
70
|
+
expect(loadOlder).not.toHaveBeenCalled()
|
|
71
|
+
expect(container.textContent).toContain('not available in the retained timeline history')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('does not retry failed paging automatically', async () => {
|
|
75
|
+
const loadOlder = vi.fn()
|
|
76
|
+
await render([row('newest', 1)], { truncated: true, loadOlder, error: new Error('unavailable') })
|
|
77
|
+
expect(loadOlder).not.toHaveBeenCalled()
|
|
78
|
+
expect(container.textContent).toContain("some history couldn't be loaded")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('lets the user select another event without a refresh forcing the original selection back', async () => {
|
|
82
|
+
const events = [row('newest', 1), row('target', 5)]
|
|
83
|
+
await render(events)
|
|
84
|
+
await act(async () => container.querySelector<HTMLElement>('[data-event-id="newest"] .cursor-pointer')!.click())
|
|
85
|
+
await render([...events, row('older', 10)])
|
|
86
|
+
expect(container.querySelector('[data-event-id="newest"] .ring-2')).not.toBeNull()
|
|
87
|
+
expect(container.querySelector('[data-event-id="target"] .ring-2')).toBeNull()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('honors a new click on the same report', async () => {
|
|
91
|
+
const events = [row('newest', 1), row('target', 5)]
|
|
92
|
+
await render(events)
|
|
93
|
+
await act(async () => container.querySelector<HTMLElement>('[data-event-id="newest"] .cursor-pointer')!.click())
|
|
94
|
+
await render(events, { target: { id: 'target' } })
|
|
95
|
+
expect(container.querySelector('[data-event-id="target"] .ring-2')).not.toBeNull()
|
|
96
|
+
})
|