@skyhook-io/k8s-ui 1.18.0 → 1.18.1
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 +13 -0
- package/package.json +1 -1
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- 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 +33 -4
- 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 +14 -9
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- 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 +106 -19
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +24 -4
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +9 -3
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +46 -31
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +6 -4
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/drawer-components.test.tsx +253 -0
- package/src/components/ui/drawer-components.tsx +315 -11
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +54 -6
- package/src/hooks/index.ts +2 -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 +66 -2
- 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/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 +22 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +56 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
|
@@ -815,24 +815,57 @@ export type CopyHandler = (text: string, key: string) => void
|
|
|
815
815
|
// RELATED RESOURCES SECTION
|
|
816
816
|
// ============================================================================
|
|
817
817
|
|
|
818
|
-
import type { TimelineEvent, Relationships, ResourceRef } from '../../types'
|
|
818
|
+
import type { TimelineEvent, Relationships, ResourceRef, OwnershipCoverage } from '../../types'
|
|
819
819
|
import { isChangeEvent, isK8sEvent } from '../../types'
|
|
820
|
-
import { Link } from 'lucide-react'
|
|
820
|
+
import { Info, Link } from 'lucide-react'
|
|
821
821
|
import { formatRelativeAgeTime } from '../../utils/format'
|
|
822
822
|
|
|
823
823
|
interface RelatedResourcesSectionProps {
|
|
824
824
|
relationships: Relationships | undefined
|
|
825
825
|
onNavigate?: (ref: ResourceRef) => void
|
|
826
|
+
/** The URL plural of the object shown (e.g. `deployments`). */
|
|
827
|
+
kind?: string
|
|
828
|
+
/** The manager the resource's tracking metadata names, when every manager's
|
|
829
|
+
* list was read, that one's doesn't list the resource and another's does. */
|
|
830
|
+
trackedBy?: string
|
|
826
831
|
}
|
|
827
832
|
|
|
828
|
-
|
|
833
|
+
// Kinds whose objects usually own others. An empty Children list for one of
|
|
834
|
+
// these is surprising, so it says it covered only the kinds Radar checked;
|
|
835
|
+
// for any other kind an empty list stays hidden, or nearly every leaf object
|
|
836
|
+
// would carry the note.
|
|
837
|
+
const KINDS_WITH_CHILDREN = new Set(['deployments', 'replicasets', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs', 'services', 'rollouts'])
|
|
838
|
+
|
|
839
|
+
export function RelatedResourcesSection({ relationships, onNavigate, kind, trackedBy }: RelatedResourcesSectionProps) {
|
|
829
840
|
if (!relationships) return null
|
|
830
841
|
|
|
831
842
|
const configRefs = omitResourceRefs(relationships.configRefs ?? [], relationships.dependencies ?? [])
|
|
832
843
|
const consumers = omitResourceRefs(relationships.consumers ?? [], relationships.dependents ?? [])
|
|
844
|
+
const alsoOwnedBy = omitResourceRefs(relationships.owners ?? [], relationships.owner ? [relationships.owner] : [])
|
|
845
|
+
const managesNote = membersNote(relationships.ownership?.manages, (relationships.manages?.length ?? 0) > 0)
|
|
846
|
+
const managers = dedupeResourceRefs(relationships.managers ?? [])
|
|
847
|
+
const managedByNote = managersNote(relationships.ownership, managers.length, trackedBy)
|
|
848
|
+
const unanswered = ownershipUnanswered(relationships.ownership)
|
|
849
|
+
const several = !relationships.owner && relationships.ownership?.primaryOwnerState === 'several'
|
|
850
|
+
const ownerMissing = relationships.owner || several ? undefined : ownerNote(relationships.ownership)
|
|
851
|
+
let childrenCoverage = childrenNote(relationships.ownership)
|
|
852
|
+
const noChildren = !relationships.children?.length && !relationships.olderRevisions?.length
|
|
853
|
+
if (noChildren && childrenCoverage?.hint && !childrenCoverage.text && kind && KINDS_WITH_CHILDREN.has(kind)) {
|
|
854
|
+
childrenCoverage = { ...childrenCoverage, text: 'No children among the kinds Radar checked.' }
|
|
855
|
+
}
|
|
833
856
|
|
|
834
857
|
const hasRelationships =
|
|
835
858
|
relationships.owner ||
|
|
859
|
+
unanswered ||
|
|
860
|
+
ownerMissing ||
|
|
861
|
+
several ||
|
|
862
|
+
childrenCoverage?.text ||
|
|
863
|
+
(relationships.olderRevisions && relationships.olderRevisions.length > 0) ||
|
|
864
|
+
alsoOwnedBy.length > 0 ||
|
|
865
|
+
managers.length > 0 ||
|
|
866
|
+
managedByNote.text ||
|
|
867
|
+
(relationships.manages && relationships.manages.length > 0) ||
|
|
868
|
+
managesNote ||
|
|
836
869
|
relationships.deployment ||
|
|
837
870
|
relationships.node ||
|
|
838
871
|
(relationships.children && relationships.children.length > 0) ||
|
|
@@ -864,8 +897,20 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
|
|
|
864
897
|
return (
|
|
865
898
|
<Section title="Related Resources" icon={Link} defaultExpanded>
|
|
866
899
|
<div className="space-y-3">
|
|
867
|
-
{
|
|
868
|
-
|
|
900
|
+
{unanswered && <div className="text-xs text-theme-text-tertiary">{unanswered}</div>}
|
|
901
|
+
{(relationships.owner || ownerMissing) && (
|
|
902
|
+
<RelationshipGroup label="Owner" refs={relationships.owner ? [relationships.owner] : []} onNavigate={onNavigate} note={ownerMissing} />
|
|
903
|
+
)}
|
|
904
|
+
{(alsoOwnedBy.length > 0 || several) && (
|
|
905
|
+
<RelationshipGroup
|
|
906
|
+
label={relationships.owner || ownerMissing ? 'Also Owned By' : 'Owners'}
|
|
907
|
+
refs={dedupeResourceRefs(alsoOwnedBy)}
|
|
908
|
+
onNavigate={onNavigate}
|
|
909
|
+
note={several ? { text: alsoOwnedBy.length > 0 ? 'None of them is its controller.' : 'None of the owners it names can be shown.' } : undefined}
|
|
910
|
+
/>
|
|
911
|
+
)}
|
|
912
|
+
{(managers.length > 0 || managedByNote.text) && (
|
|
913
|
+
<RelationshipGroup label="Managed By" refs={managers} onNavigate={onNavigate} note={managedByNote} />
|
|
869
914
|
)}
|
|
870
915
|
{relationships.deployment && (
|
|
871
916
|
<RelationshipGroup label="Deployment" refs={[relationships.deployment]} onNavigate={onNavigate} />
|
|
@@ -873,8 +918,18 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
|
|
|
873
918
|
{relationships.node && (
|
|
874
919
|
<RelationshipGroup label="Node" refs={[relationships.node]} onNavigate={onNavigate} />
|
|
875
920
|
)}
|
|
876
|
-
{relationships.children && relationships.children.length > 0 && (
|
|
877
|
-
<RelationshipGroup
|
|
921
|
+
{((relationships.children && relationships.children.length > 0) || childrenCoverage?.text || (relationships.olderRevisions && relationships.olderRevisions.length > 0)) && (
|
|
922
|
+
<RelationshipGroup
|
|
923
|
+
label="Children"
|
|
924
|
+
refs={dedupeResourceRefs(relationships.children ?? [])}
|
|
925
|
+
onNavigate={onNavigate}
|
|
926
|
+
note={childrenCoverage}
|
|
927
|
+
more={olderRevisions(relationships.olderRevisions)}
|
|
928
|
+
capped={relationships.ownership?.children?.truncated}
|
|
929
|
+
/>
|
|
930
|
+
)}
|
|
931
|
+
{((relationships.manages && relationships.manages.length > 0) || managesNote) && (
|
|
932
|
+
<RelationshipGroup label="Manages" refs={dedupeResourceRefs(relationships.manages ?? [])} onNavigate={onNavigate} note={managesNote} capped={relationships.ownership?.manages?.truncated} />
|
|
878
933
|
)}
|
|
879
934
|
{relationships.services && relationships.services.length > 0 && (
|
|
880
935
|
<RelationshipGroup label="Services" refs={dedupeResourceRefs(relationships.services)} onNavigate={onNavigate} />
|
|
@@ -947,23 +1002,245 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
|
|
|
947
1002
|
)
|
|
948
1003
|
}
|
|
949
1004
|
|
|
1005
|
+
// ownershipUnanswered says why Owner and Children couldn't be answered at all.
|
|
1006
|
+
function ownershipUnanswered(coverage: OwnershipCoverage | undefined): string | undefined {
|
|
1007
|
+
const reason = coverage?.unavailableReason
|
|
1008
|
+
if (!reason) return undefined
|
|
1009
|
+
if (reason === 'user_forbidden') return "Owner and children aren't shown: your access lets you open this object, but not list its kind here."
|
|
1010
|
+
return `Owner and children couldn't be read: ${gapReasonText(reason)}.`
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// ownerNote says why no owner is named, when there is an owner reference.
|
|
1014
|
+
function ownerNote(coverage: OwnershipCoverage | undefined): RelationshipNote | undefined {
|
|
1015
|
+
const ref = coverage?.ownerRef
|
|
1016
|
+
const named = ref?.kind && ref.name ? `${ref.kind} ${ref.name}` : 'the owner it names'
|
|
1017
|
+
switch (coverage?.primaryOwnerState) {
|
|
1018
|
+
case 'gone':
|
|
1019
|
+
return { text: `${capitalize(named)} no longer exists.` }
|
|
1020
|
+
case 'replaced':
|
|
1021
|
+
return { text: `${capitalize(named)} was deleted and recreated; this object names the earlier one.` }
|
|
1022
|
+
case 'unknown':
|
|
1023
|
+
return { text: coverage?.ownerReason ? `Couldn't check ${named} (${gapReasonText(coverage.ownerReason)}).` : `Couldn't check ${named}.` }
|
|
1024
|
+
case 'withheld':
|
|
1025
|
+
return { text: 'Not visible to you.' }
|
|
1026
|
+
case 'unresolvable':
|
|
1027
|
+
return { text: "Its owner reference can't be resolved in this cluster." }
|
|
1028
|
+
case 'excluded':
|
|
1029
|
+
return { text: "Its owner reference names an Event, which Radar doesn't follow as an owner." }
|
|
1030
|
+
}
|
|
1031
|
+
return undefined
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// childrenNote says what a Children list may be missing. Kinds Radar doesn't
|
|
1035
|
+
// collect are a hint, since most lists miss a few; kinds it couldn't check
|
|
1036
|
+
// for any other reason, a list cut short, and places the viewer may not read
|
|
1037
|
+
// are said outright.
|
|
1038
|
+
function childrenNote(coverage: OwnershipCoverage | undefined): RelationshipNote | undefined {
|
|
1039
|
+
const children = coverage?.children
|
|
1040
|
+
if (!children) return undefined
|
|
1041
|
+
const uncollected = new Set<string>()
|
|
1042
|
+
let someNamespaces = false
|
|
1043
|
+
let undiscovered = false
|
|
1044
|
+
const pressing = new Map<string, string>()
|
|
1045
|
+
for (const g of children.notChecked ?? []) {
|
|
1046
|
+
if (!g.kind) undiscovered = true
|
|
1047
|
+
else if (g.reason === 'radar_kind_not_collected') uncollected.add(g.kind)
|
|
1048
|
+
else if (g.reason === 'radar_namespace_not_collected') someNamespaces = true
|
|
1049
|
+
else if (!pressing.has(g.kind)) pressing.set(g.kind, gapReasonText(g.reason))
|
|
1050
|
+
}
|
|
1051
|
+
const note: RelationshipNote = {}
|
|
1052
|
+
const hint: string[] = []
|
|
1053
|
+
if (uncollected.size > 0) hint.push(`Radar doesn't collect ${joinOr(Array.from(uncollected))} objects, so none of those are listed.`)
|
|
1054
|
+
if (someNamespaces) hint.push("Radar doesn't collect every namespace, so children in the others aren't listed.")
|
|
1055
|
+
// An API group whose discovery fails, often a metrics API with no server
|
|
1056
|
+
// behind it, leaves every answer with this gap; most such groups hold no
|
|
1057
|
+
// children, so it is a hint, not a warning.
|
|
1058
|
+
if (undiscovered) hint.push("Radar's API discovery missed some API groups, so children of their kinds aren't listed.")
|
|
1059
|
+
if (children.narrowed) hint.push('Only children you can view are listed.')
|
|
1060
|
+
if (hint.length > 0) note.hint = hint
|
|
1061
|
+
const text: string[] = []
|
|
1062
|
+
if (pressing.size > 0) text.push(`Couldn't check ${Array.from(pressing, ([kind, why]) => `${kind} (${why})`).join(', ')}.`)
|
|
1063
|
+
if (children.truncated) text.push('More children than shown.')
|
|
1064
|
+
if (text.length > 0) note.text = text.join(' ')
|
|
1065
|
+
return note.hint || note.text ? note : undefined
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function capitalize(s: string): string {
|
|
1069
|
+
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function joinOr(items: string[]): string {
|
|
1073
|
+
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function gapReasonText(reason: string): string {
|
|
1077
|
+
switch (reason) {
|
|
1078
|
+
case 'radar_kind_not_collected':
|
|
1079
|
+
case 'radar_namespace_not_collected':
|
|
1080
|
+
return "Radar doesn't collect it"
|
|
1081
|
+
case 'kind_sync_pending':
|
|
1082
|
+
return 'still loading'
|
|
1083
|
+
case 'kind_sync_timed_out':
|
|
1084
|
+
return 'loading is overdue'
|
|
1085
|
+
case 'radar_forbidden':
|
|
1086
|
+
case 'radar_credentials_rejected':
|
|
1087
|
+
return 'Radar may not list it'
|
|
1088
|
+
case 'kind_not_watchable':
|
|
1089
|
+
return "its API can't be watched"
|
|
1090
|
+
case 'user_permission_check_failed':
|
|
1091
|
+
return "your access couldn't be checked"
|
|
1092
|
+
case 'user_forbidden':
|
|
1093
|
+
return 'you may not list it'
|
|
1094
|
+
case 'kind_unknown':
|
|
1095
|
+
return "Radar's API discovery doesn't list its kind or version"
|
|
1096
|
+
case 'object_not_found':
|
|
1097
|
+
return "Radar doesn't have it yet"
|
|
1098
|
+
case 'cluster_connecting':
|
|
1099
|
+
return 'Radar is still connecting'
|
|
1100
|
+
case 'cluster_disconnected':
|
|
1101
|
+
return 'Radar lost its connection'
|
|
1102
|
+
case 'connection_changed':
|
|
1103
|
+
return "Radar's connection changed meanwhile"
|
|
1104
|
+
default:
|
|
1105
|
+
return 'the read failed'
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// membersNote says what a GitOps manager's status records beyond the members
|
|
1110
|
+
// Manages lists; listed says whether it lists any.
|
|
1111
|
+
function membersNote(manages: OwnershipCoverage['manages'] | undefined, listed: boolean): RelationshipNote | undefined {
|
|
1112
|
+
if (!manages) return undefined
|
|
1113
|
+
if (manages.membership === 'remote') return { text: "Its destination isn't this cluster as far as Radar can tell, so its members aren't listed." }
|
|
1114
|
+
if (manages.membership === 'unreadable') {
|
|
1115
|
+
const note: RelationshipNote = {}
|
|
1116
|
+
const text: string[] = []
|
|
1117
|
+
if (manages.unavailableReason) text.push(`Couldn't read the members its status records (${gapReasonText(manages.unavailableReason)}).`)
|
|
1118
|
+
if (manages.truncated) text.push('More members than shown.')
|
|
1119
|
+
if (text.length > 0) note.text = text.join(' ')
|
|
1120
|
+
if (listed) note.hint = ["Its status has no member list Radar can read now, so these are the resources the topology links to it."]
|
|
1121
|
+
else if (!manages.unavailableReason) note.hint = ["Its status has no member list Radar can read now."]
|
|
1122
|
+
return note
|
|
1123
|
+
}
|
|
1124
|
+
const extra: string[] = []
|
|
1125
|
+
if (manages.missing) extra.push(`${manages.missing} not in the cluster`)
|
|
1126
|
+
if (manages.withheld) extra.push(`${manages.withheld} you can't view`)
|
|
1127
|
+
if (manages.unknown) extra.push(`${manages.unknown} Radar can't check`)
|
|
1128
|
+
const parts: string[] = []
|
|
1129
|
+
if (extra.length > 0) parts.push(`Its status also lists ${extra.join(', ')}.`)
|
|
1130
|
+
if (manages.truncated) parts.push('More members than shown.')
|
|
1131
|
+
if (parts.length === 0 && !listed) parts.push('Its status records no members to show.')
|
|
1132
|
+
return parts.length > 0 ? { text: parts.join(' ') } : undefined
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
export interface RelationshipNote {
|
|
1136
|
+
/** Shown under the group. */
|
|
1137
|
+
text?: string
|
|
1138
|
+
/** Lines shown on hovering an info mark beside the label. */
|
|
1139
|
+
hint?: string[]
|
|
1140
|
+
}
|
|
1141
|
+
|
|
950
1142
|
interface RelationshipGroupProps {
|
|
951
1143
|
label: string
|
|
952
1144
|
refs: ResourceRef[]
|
|
953
1145
|
onNavigate?: (ref: ResourceRef) => void
|
|
1146
|
+
note?: RelationshipNote
|
|
1147
|
+
/** Refs shown only on request, such as a Deployment's old ReplicaSets. */
|
|
1148
|
+
more?: MoreRefs
|
|
1149
|
+
/** The server cut the list short, so refs is not every one. */
|
|
1150
|
+
capped?: boolean
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
interface MoreRefs {
|
|
1154
|
+
refs: ResourceRef[]
|
|
1155
|
+
/** What the refs are, as "older ReplicaSets" reads after a count. */
|
|
1156
|
+
label: string
|
|
1157
|
+
/** Why they're kept apart, shown on hovering the toggle. */
|
|
1158
|
+
hint: string
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// olderRevisions are the ReplicaSets the server keeps out of Children because
|
|
1162
|
+
// they only hold rollback history: listed there, ten of them would bury the
|
|
1163
|
+
// ones running Pods.
|
|
1164
|
+
function olderRevisions(refs: ResourceRef[] | undefined): MoreRefs | undefined {
|
|
1165
|
+
if (!refs || refs.length === 0) return undefined
|
|
1166
|
+
const unique = dedupeResourceRefs(refs)
|
|
1167
|
+
return {
|
|
1168
|
+
refs: unique,
|
|
1169
|
+
label: unique.length === 1 ? 'older ReplicaSet' : 'older ReplicaSets',
|
|
1170
|
+
hint: 'Earlier revisions kept for rollback. They run no Pods.',
|
|
1171
|
+
}
|
|
954
1172
|
}
|
|
955
1173
|
|
|
956
1174
|
const RELATIONSHIP_TRUNCATE_LIMIT = 10
|
|
957
1175
|
|
|
958
|
-
|
|
1176
|
+
// Managed By sits next to Owner and reads like it, so it says how they differ
|
|
1177
|
+
// and which direction its answer comes from.
|
|
1178
|
+
const MANAGED_BY_HINT = [
|
|
1179
|
+
'Argo CD Applications or Flux objects whose status lists this resource, and other controllers that manage it without an owner reference.',
|
|
1180
|
+
"Kubernetes' garbage collector follows owner references, not these links.",
|
|
1181
|
+
]
|
|
1182
|
+
|
|
1183
|
+
// managersNote explains Managed By: where it comes from, more than one
|
|
1184
|
+
// manager, and the managers the search couldn't settle. With none listed, a
|
|
1185
|
+
// remote list naming the resource or a manager kind Radar couldn't search is
|
|
1186
|
+
// said outright, so an empty group doesn't read as "no GitOps manager". An
|
|
1187
|
+
// unreadable list stays in the hint: it may name any resource, so it would
|
|
1188
|
+
// otherwise show beside every one, and the controllers stamp tracking
|
|
1189
|
+
// metadata on what they apply, through which the topology names such a
|
|
1190
|
+
// manager anyway.
|
|
1191
|
+
function managersNote(coverage: OwnershipCoverage | undefined, listed: number, trackedBy?: string): RelationshipNote {
|
|
1192
|
+
const hint = [...MANAGED_BY_HINT]
|
|
1193
|
+
const open: string[] = []
|
|
1194
|
+
const coverageOf = coverage?.managers
|
|
1195
|
+
if (coverageOf?.unreadable) {
|
|
1196
|
+
const n = coverageOf.unreadable
|
|
1197
|
+
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.`)
|
|
1198
|
+
}
|
|
1199
|
+
if (coverageOf?.remote) {
|
|
1200
|
+
const n = coverageOf.remote
|
|
1201
|
+
open.push(`${n} ${n === 1 ? 'manager lists' : 'managers list'} it for a cluster Radar can't confirm is this one`)
|
|
1202
|
+
}
|
|
1203
|
+
const pressing = new Map<string, string>()
|
|
1204
|
+
for (const g of coverageOf?.notChecked ?? []) {
|
|
1205
|
+
// An unserved manager kind under incomplete discovery reads as read_error
|
|
1206
|
+
// on every resource, like discovery's own gap.
|
|
1207
|
+
if (!g.kind) hint.push("Radar's API discovery missed some API groups, so managers of their kinds aren't checked.")
|
|
1208
|
+
else if (g.reason === 'read_error') hint.push(`Radar couldn't search ${g.kind} objects, so those managers aren't checked.`)
|
|
1209
|
+
else if (g.reason === 'radar_kind_not_collected') hint.push(`Radar doesn't collect ${g.kind} objects, so those managers aren't checked.`)
|
|
1210
|
+
else if (!pressing.has(g.kind)) pressing.set(g.kind, gapReasonText(g.reason))
|
|
1211
|
+
}
|
|
1212
|
+
if (pressing.size > 0) open.push(`couldn't check ${Array.from(pressing, ([kind, why]) => `${kind} (${why})`).join(', ')}`)
|
|
1213
|
+
const text: string[] = []
|
|
1214
|
+
if (trackedBy) text.push(`Its tracking label names ${trackedBy}, whose status doesn't list it.`)
|
|
1215
|
+
if (open.length > 0) {
|
|
1216
|
+
const sentence = `${capitalize(open.join('; '))}.`
|
|
1217
|
+
if (listed === 0) text.push(sentence)
|
|
1218
|
+
else hint.push(sentence)
|
|
1219
|
+
}
|
|
1220
|
+
if (listed > 1) text.push('More than one manager lists or links to it.')
|
|
1221
|
+
const note: RelationshipNote = { hint: [...new Set(hint)] }
|
|
1222
|
+
if (text.length > 0) note.text = text.join(' ')
|
|
1223
|
+
return note
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
export function RelationshipGroup({ label, refs, onNavigate, note, more, capped }: RelationshipGroupProps) {
|
|
959
1227
|
const [showAll, setShowAll] = useState(false)
|
|
960
|
-
if (!refs || refs.length === 0) return null
|
|
1228
|
+
if ((!refs || refs.length === 0) && !note && !more) return null
|
|
961
1229
|
const truncated = !showAll && refs.length > RELATIONSHIP_TRUNCATE_LIMIT
|
|
962
1230
|
const visibleRefs = truncated ? refs.slice(0, RELATIONSHIP_TRUNCATE_LIMIT) : refs
|
|
963
1231
|
|
|
964
1232
|
return (
|
|
965
1233
|
<div>
|
|
966
|
-
<div className="text-xs text-theme-text-tertiary mb-1
|
|
1234
|
+
<div className="text-xs text-theme-text-tertiary mb-1 flex items-center gap-1">
|
|
1235
|
+
<span>{label}{refs.length > 1 ? ` (${refs.length}${capped ? '+' : ''})` : ''}</span>
|
|
1236
|
+
{note?.hint && (
|
|
1237
|
+
<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">
|
|
1238
|
+
<button type="button" className="inline-flex rounded text-theme-text-tertiary hover:text-theme-text-secondary" aria-label={note.hint.join(' ')}>
|
|
1239
|
+
<Info className="w-3 h-3" aria-hidden="true" />
|
|
1240
|
+
</button>
|
|
1241
|
+
</Tooltip>
|
|
1242
|
+
)}
|
|
1243
|
+
</div>
|
|
967
1244
|
<div className="flex flex-wrap gap-1">
|
|
968
1245
|
{visibleRefs.map((resourceRef, i) => (
|
|
969
1246
|
<ResourceRefBadge key={`${resourceRef.kind}-${resourceRef.namespace}-${resourceRef.name}-${i}`} resourceRef={resourceRef} onClick={onNavigate} />
|
|
@@ -973,10 +1250,37 @@ export function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroup
|
|
|
973
1250
|
onClick={() => setShowAll(true)}
|
|
974
1251
|
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
1252
|
>
|
|
976
|
-
Show all {refs.length}
|
|
1253
|
+
{capped ? `Show the ${refs.length} listed` : `Show all ${refs.length}`}
|
|
977
1254
|
</button>
|
|
978
1255
|
)}
|
|
979
1256
|
</div>
|
|
1257
|
+
{more && <MoreRefsToggle more={more} onNavigate={onNavigate} />}
|
|
1258
|
+
{note?.text && <div className="mt-1 text-xs text-theme-text-tertiary">{note.text}</div>}
|
|
1259
|
+
</div>
|
|
1260
|
+
)
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function MoreRefsToggle({ more, onNavigate }: { more: MoreRefs; onNavigate?: (ref: ResourceRef) => void }) {
|
|
1264
|
+
const [open, setOpen] = useState(false)
|
|
1265
|
+
const { panelId, buttonProps } = useDisclosure(open)
|
|
1266
|
+
return (
|
|
1267
|
+
<div className="mt-1">
|
|
1268
|
+
<button
|
|
1269
|
+
{...buttonProps}
|
|
1270
|
+
onClick={() => setOpen(!open)}
|
|
1271
|
+
title={more.hint}
|
|
1272
|
+
className="flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
|
|
1273
|
+
>
|
|
1274
|
+
<CollapseChevron open={open} className="w-3 h-3" />
|
|
1275
|
+
{open ? `Hide ${more.label}` : `Show ${more.refs.length} ${more.label}`}
|
|
1276
|
+
</button>
|
|
1277
|
+
<Collapse open={open} id={panelId} mountLazily>
|
|
1278
|
+
<div className="flex flex-wrap gap-1 mt-1">
|
|
1279
|
+
{more.refs.map((resourceRef, i) => (
|
|
1280
|
+
<ResourceRefBadge key={`${resourceRef.kind}-${resourceRef.namespace}-${resourceRef.name}-${i}`} resourceRef={resourceRef} onClick={onNavigate} />
|
|
1281
|
+
))}
|
|
1282
|
+
</div>
|
|
1283
|
+
</Collapse>
|
|
980
1284
|
</div>
|
|
981
1285
|
)
|
|
982
1286
|
}
|
|
@@ -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,36 @@ 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
|
+
})
|
|
@@ -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
|
+
})
|