@skyhook-io/k8s-ui 1.16.3 → 1.17.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 +41 -0
- package/package.json +3 -3
- package/src/components/applications/ApplicationDetail.test.tsx +15 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -15
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/checks/types.ts +2 -13
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/GitOpsTableView.tsx +9 -24
- package/src/components/gitops/detail-helpers.ts +1 -1
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +29 -83
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +40 -60
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
- package/src/components/gitops/tree/tree-helpers.ts +7 -1
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +40 -5
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +14 -3
- package/src/components/issues/issues.test.ts +50 -2
- package/src/components/issues/severity.ts +21 -0
- package/src/components/issues/types.ts +23 -0
- package/src/components/logs/LogCore.theme.test.tsx +81 -3
- package/src/components/logs/LogCore.tsx +25 -17
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +291 -0
- package/src/components/logs/LogsViewer.tsx +72 -11
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +22 -30
- package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +44 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +28 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +309 -117
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +5 -4
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +7 -8
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +9 -17
- package/src/components/resources/renderers/CNPGShared.tsx +2 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +62 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +15 -14
- package/src/components/resources/renderers/EventRenderer.tsx +3 -3
- package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
- package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/HPARenderer.tsx +57 -17
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +36 -5
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
- package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +15 -13
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
- package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
- package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
- package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
- package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +62 -36
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +9 -22
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -24
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-keda.ts +1 -7
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils-kyverno.ts +1 -7
- package/src/components/resources/resource-utils-velero.ts +0 -12
- package/src/components/resources/resource-utils.ts +99 -39
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/DrainPlanDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
- package/src/components/shared/ResourceActionsBar.tsx +20 -24
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -10
- package/src/components/shared/SetImageDialog.test.ts +75 -6
- package/src/components/shared/SetImageDialog.tsx +92 -84
- package/src/components/shared/index.ts +4 -1
- package/src/components/timeline/TimelineList.tsx +2 -15
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -11
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +8 -74
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/ScheduleValue.test.tsx +5 -0
- package/src/components/ui/ScheduleValue.tsx +3 -2
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +175 -1
- package/src/components/ui/drawer-components.tsx +103 -36
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +101 -0
- package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +297 -104
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +21 -2
- package/src/types/core.ts +60 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +10 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +49 -2
- package/src/utils/format.ts +9 -1
- package/src/utils/gateway-references.test.ts +17 -0
- package/src/utils/gateway-references.ts +29 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/gitops-write-guard.test.ts +401 -0
- package/src/utils/gitops-write-guard.ts +509 -0
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/ingress-class-references.ts +22 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +70 -0
- package/src/utils/navigation.ts +91 -8
- package/src/utils/node-lifecycle.test.ts +36 -0
- package/src/utils/node-lifecycle.ts +77 -0
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- package/src/utils/resource-hierarchy.ts +41 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/resource-refs.test.ts +23 -0
- package/src/utils/resource-refs.ts +30 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
|
@@ -29,6 +29,7 @@ import { servicePortsTooltip, type ServicePortEntry } from '../../topology/K8sRe
|
|
|
29
29
|
import { getTopologyIcon } from '../../../utils/resource-icons'
|
|
30
30
|
import { Tooltip } from '../../ui/Tooltip'
|
|
31
31
|
import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
|
|
32
|
+
import { rankTree, renderedEdge, shortcutEdges, upstreamClosure, upstreamEdges } from './layout'
|
|
32
33
|
|
|
33
34
|
export type GitOpsTreePreset = 'full' | 'compact' | 'workloads' | 'app'
|
|
34
35
|
|
|
@@ -231,7 +232,7 @@ function GitOpsTreeGraphInner({
|
|
|
231
232
|
function EdgeLegend({ edges }: { edges: Edge[] }) {
|
|
232
233
|
const types = new Set(edges.map((e) => e.data?.type as string).filter(Boolean))
|
|
233
234
|
const rows: Array<{ key: string; color: string; label: string }> = []
|
|
234
|
-
if (types.has('source')) rows.push({ key: 'source', color: getEdgeColor('source'), label: 'Source
|
|
235
|
+
if (types.has('source')) rows.push({ key: 'source', color: getEdgeColor('source'), label: 'Source artifact' })
|
|
235
236
|
if (types.has('dependsOn')) rows.push({ key: 'dependsOn', color: getEdgeColor('dependsOn'), label: 'Depends on' })
|
|
236
237
|
// Always show the default ownership color when there are 2+ edge kinds —
|
|
237
238
|
// operators need to compare against it. Hide when "owns" is the only kind.
|
|
@@ -333,16 +334,10 @@ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePrese
|
|
|
333
334
|
if (!tree) return { nodes: [], edges: [] }
|
|
334
335
|
const visibleTree = applyGraphFilters(applyPreset(tree, preset, expandedGroups), filters)
|
|
335
336
|
const byID = new Map(visibleTree.nodes.map(node => [node.id, node]))
|
|
336
|
-
const
|
|
337
|
-
const incoming = new Map<string, number>()
|
|
338
|
-
for (const edge of visibleTree.edges) {
|
|
339
|
-
if (!byID.has(edge.source) || !byID.has(edge.target)) continue
|
|
340
|
-
children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
|
|
341
|
-
incoming.set(edge.target, (incoming.get(edge.target) ?? 0) + 1)
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const ranks = assignRanks(visibleTree.root.id, byID, children, incoming)
|
|
337
|
+
const ranks = groupRanks(rankTree(visibleTree), byID)
|
|
345
338
|
const positioned = positionRanks(ranks, byID)
|
|
339
|
+
const upstreamTargets = new Set(upstreamEdges(visibleTree).map(edge => edge.target))
|
|
340
|
+
const shortcuts = shortcutEdges(visibleTree)
|
|
346
341
|
const normalizedQuery = query.trim().toLowerCase()
|
|
347
342
|
const nodes = Array.from(positioned.entries()).map(([id, position]) => {
|
|
348
343
|
const node = byID.get(id)!
|
|
@@ -359,11 +354,10 @@ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePrese
|
|
|
359
354
|
})
|
|
360
355
|
|
|
361
356
|
const edges = visibleTree.edges
|
|
362
|
-
.filter(edge => positioned.has(edge.source) && positioned.has(edge.target))
|
|
357
|
+
.filter(edge => positioned.has(edge.source) && positioned.has(edge.target) && !shortcuts.has(edge))
|
|
363
358
|
.map(edge => ({
|
|
364
359
|
id: `${edge.source}->${edge.target}`,
|
|
365
|
-
|
|
366
|
-
target: edge.target,
|
|
360
|
+
...renderedEdge(edge, upstreamTargets),
|
|
367
361
|
type: 'smoothstep',
|
|
368
362
|
// The legend reads back edge.data?.type to decide which rows to render —
|
|
369
363
|
// ReactFlow doesn't preserve our custom `type` field at the top level
|
|
@@ -394,7 +388,9 @@ function applyPreset(tree: GitOpsResourceTree, preset: GitOpsTreePreset, expande
|
|
|
394
388
|
children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
|
|
395
389
|
}
|
|
396
390
|
|
|
397
|
-
|
|
391
|
+
// Dependencies answer "where does this come from" and are few, so every
|
|
392
|
+
// preset keeps them.
|
|
393
|
+
const keep = new Set<string>([tree.root.id, ...upstreamClosure(tree)])
|
|
398
394
|
if (preset === 'app') {
|
|
399
395
|
for (const edge of tree.edges) {
|
|
400
396
|
if (edge.source === tree.root.id) keep.add(edge.target)
|
|
@@ -438,7 +434,7 @@ function applyGraphFilters(tree: GitOpsResourceTree, filters?: GitOpsTreeFilters
|
|
|
438
434
|
if (node.id === tree.root.id) continue
|
|
439
435
|
if (matchesGitOpsTreeFilters(node, filters)) {
|
|
440
436
|
let current: string | undefined = node.id
|
|
441
|
-
while (current) {
|
|
437
|
+
while (current && !keep.has(current)) {
|
|
442
438
|
keep.add(current)
|
|
443
439
|
current = parent.get(current)
|
|
444
440
|
}
|
|
@@ -506,43 +502,10 @@ function compactInfra(tree: GitOpsResourceTree, expandedGroups?: Set<string>): G
|
|
|
506
502
|
}
|
|
507
503
|
}
|
|
508
504
|
|
|
509
|
-
function
|
|
510
|
-
rootID: string,
|
|
511
|
-
nodes: Map<string, GitOpsTreeNode>,
|
|
512
|
-
children: Map<string, string[]>,
|
|
513
|
-
incoming: Map<string, number>
|
|
514
|
-
): Map<number, string[]> {
|
|
515
|
-
const rankByID = new Map<string, number>()
|
|
516
|
-
const queue = [{ id: rootID, rank: 0 }]
|
|
517
|
-
// Cycle/explosion guard: GitOps trees are owner-ref DAGs in practice, but
|
|
518
|
-
// the EdgeManages set we feed in could pathologically include a cycle
|
|
519
|
-
// (e.g. a CRD whose owner ref points back at one of its own children, or
|
|
520
|
-
// a malformed inventory). Without this cap, the "previous >= rank" skip
|
|
521
|
-
// never fires for cyclic edges and the loop runs forever, freezing the
|
|
522
|
-
// UI thread. Cap at a large multiple of node count so legitimate trees
|
|
523
|
-
// never hit it; cycles bail with the partial layout we have so far.
|
|
524
|
-
let iterations = 0
|
|
525
|
-
const maxIterations = nodes.size * 8 + 64
|
|
526
|
-
|
|
527
|
-
while (queue.length > 0) {
|
|
528
|
-
if (++iterations > maxIterations) break
|
|
529
|
-
const current = queue.shift()!
|
|
530
|
-
const previous = rankByID.get(current.id)
|
|
531
|
-
if (previous !== undefined && previous >= current.rank) continue
|
|
532
|
-
rankByID.set(current.id, current.rank)
|
|
533
|
-
for (const child of children.get(current.id) ?? []) {
|
|
534
|
-
queue.push({ id: child, rank: current.rank + 1 })
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
for (const id of nodes.keys()) {
|
|
539
|
-
if (!rankByID.has(id)) {
|
|
540
|
-
rankByID.set(id, incoming.get(id) ? 1 : 0)
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
|
|
505
|
+
function groupRanks(rankByID: Map<string, number>, nodes: Map<string, GitOpsTreeNode>): Map<number, string[]> {
|
|
544
506
|
const ranks = new Map<number, string[]>()
|
|
545
507
|
for (const [id, rank] of rankByID.entries()) {
|
|
508
|
+
if (!nodes.has(id)) continue
|
|
546
509
|
ranks.set(rank, [...(ranks.get(rank) ?? []), id])
|
|
547
510
|
}
|
|
548
511
|
for (const ids of ranks.values()) {
|
|
@@ -565,12 +528,23 @@ function positionRanks(ranks: Map<number, string[]>, nodes: Map<string, GitOpsTr
|
|
|
565
528
|
const rootRank = ranks.get(0) ?? []
|
|
566
529
|
if (rootRank.length === 1) {
|
|
567
530
|
const rootID = rootRank[0]
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const
|
|
531
|
+
const rootHeight = getNodeDimensions(nodes.get(rootID)!).height
|
|
532
|
+
const tallest = Math.max((ranks.get(1)?.length ?? 0), (ranks.get(-1)?.length ?? 0))
|
|
533
|
+
if (tallest > 0) {
|
|
534
|
+
const maxY = (tallest - 1) * ROW_GAP
|
|
572
535
|
positioned.set(rootID, { x: 0, y: Math.max(0, (maxY - rootHeight) / 2 - ROOT_Y_OFFSET) })
|
|
573
536
|
}
|
|
537
|
+
// Dependency columns are short; center them on the root so the chain
|
|
538
|
+
// reads as one line into it.
|
|
539
|
+
const rootCenter = positioned.get(rootID)!.y + rootHeight / 2
|
|
540
|
+
for (const rank of sortedRanks) {
|
|
541
|
+
if (rank >= 0) continue
|
|
542
|
+
const ids = ranks.get(rank) ?? []
|
|
543
|
+
const columnCenter = ((ids.length - 1) * ROW_GAP + rootHeight) / 2
|
|
544
|
+
ids.forEach((id, row) => {
|
|
545
|
+
positioned.set(id, { x: rank * RANK_GAP, y: row * ROW_GAP + rootCenter - columnCenter })
|
|
546
|
+
})
|
|
547
|
+
}
|
|
574
548
|
}
|
|
575
549
|
|
|
576
550
|
return positioned
|
|
@@ -587,7 +561,7 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
|
|
|
587
561
|
const KindIcon = getTopologyIcon(kind)
|
|
588
562
|
// Only unhealthy nodes carry a cause worth surfacing — a healthy node
|
|
589
563
|
// matched by a stale/unrelated issue ref shouldn't show a tooltip.
|
|
590
|
-
const cause =
|
|
564
|
+
const cause = nodeCause(node, data.cause)
|
|
591
565
|
// Only declared resources can carry the controller's verdict, so only
|
|
592
566
|
// there does "this one is Radar's" mean anything. Generated children
|
|
593
567
|
// (ReplicaSets, Pods) are always Radar's read; marking each would be
|
|
@@ -752,10 +726,10 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
|
|
|
752
726
|
// detail title row and fleet rows.
|
|
753
727
|
if (isNodeTerminating(node)) {
|
|
754
728
|
chips.push({ value: 'Terminating', tone: 'warning' })
|
|
755
|
-
if (typeof data.createdAt === 'string') chips.push({ label: 'age', value:
|
|
729
|
+
if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatCompactAge(data.createdAt) })
|
|
756
730
|
return chips
|
|
757
731
|
}
|
|
758
|
-
if (typeof data.createdAt === 'string') chips.push({ label: 'age', value:
|
|
732
|
+
if (typeof data.createdAt === 'string') chips.push({ label: 'age', value: formatCompactAge(data.createdAt) })
|
|
759
733
|
const revision = stringData(data.revision) || stringData(data.lastSyncRevision)
|
|
760
734
|
if (revision) chips.push({ label: 'rev', value: revision })
|
|
761
735
|
const attempted = stringData(data.attemptedRevision)
|
|
@@ -771,6 +745,15 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
|
|
|
771
745
|
return chips
|
|
772
746
|
}
|
|
773
747
|
|
|
748
|
+
// nodeCause returns the matched issue's cause when the node's hover should
|
|
749
|
+
// show it: only on a node that is actually failing (Degraded or Missing,
|
|
750
|
+
// topology 'unhealthy'). 'degraded' is also where Progressing and Suspended
|
|
751
|
+
// land, and a cause there reads as "this is broken" on a rollout in flight
|
|
752
|
+
// or a deliberate pause.
|
|
753
|
+
export function nodeCause(node: GitOpsTreeNode, cause?: string): string | undefined {
|
|
754
|
+
return normalizeHealth(node.topologyStatus) === 'unhealthy' ? cause : undefined
|
|
755
|
+
}
|
|
756
|
+
|
|
774
757
|
// The subtitle is the node's one line of prose, and sync, health and the
|
|
775
758
|
// backend's info line all want it. It can't be won on precedence: the backend
|
|
776
759
|
// derives a health for every node it can build an info line for, so a rule that
|
|
@@ -886,6 +869,3 @@ function pluralize(kind: string): string {
|
|
|
886
869
|
return `${kind}s`
|
|
887
870
|
}
|
|
888
871
|
|
|
889
|
-
function formatAge(timestamp: string): string {
|
|
890
|
-
return formatCompactAge(timestamp)
|
|
891
|
-
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { GitOpsResourceTree, GitOpsTreeEdge, GitOpsTreeNode } from '../../../types'
|
|
4
|
+
import { rankTree, renderedEdge, shortcutEdges, upstreamClosure, upstreamEdges } from './layout'
|
|
5
|
+
|
|
6
|
+
function node(id: string, kind = 'Deployment', role: GitOpsTreeNode['role'] = 'declared'): GitOpsTreeNode {
|
|
7
|
+
return { id, ref: { kind, namespace: 'flux-system', name: id }, role, tool: 'fluxcd' }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function tree(nodes: GitOpsTreeNode[], edges: GitOpsTreeEdge[]): GitOpsResourceTree {
|
|
11
|
+
return { root: nodes[0], nodes, edges }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('rankTree', () => {
|
|
15
|
+
it('draws a release\'s generated chart and repository upstream, repository first', () => {
|
|
16
|
+
// The release names the repository directly and through its chart; the
|
|
17
|
+
// longer path wins so the chain reads repository → chart → release.
|
|
18
|
+
const t = tree(
|
|
19
|
+
[node('release', 'HelmRelease', 'root'), node('chart', 'HelmChart'), node('repo', 'HelmRepository'), node('deploy')],
|
|
20
|
+
[
|
|
21
|
+
{ source: 'release', target: 'repo', type: 'source' },
|
|
22
|
+
{ source: 'release', target: 'chart', type: 'source' },
|
|
23
|
+
{ source: 'chart', target: 'repo', type: 'source' },
|
|
24
|
+
{ source: 'release', target: 'deploy', type: 'owns' },
|
|
25
|
+
],
|
|
26
|
+
)
|
|
27
|
+
const rank = rankTree(t)
|
|
28
|
+
expect(rank.get('release')).toBe(0)
|
|
29
|
+
expect(rank.get('chart')).toBe(-1)
|
|
30
|
+
expect(rank.get('repo')).toBe(-2)
|
|
31
|
+
expect(rank.get('deploy')).toBe(1)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('keeps managed resources downstream even when they are also a source (bootstrap)', () => {
|
|
35
|
+
const t = tree(
|
|
36
|
+
[node('flux-system', 'Kustomization', 'root'), node('repo', 'GitRepository'), node('controller')],
|
|
37
|
+
[
|
|
38
|
+
{ source: 'flux-system', target: 'repo', type: 'owns' },
|
|
39
|
+
{ source: 'flux-system', target: 'repo', type: 'source' },
|
|
40
|
+
{ source: 'flux-system', target: 'controller', type: 'owns' },
|
|
41
|
+
],
|
|
42
|
+
)
|
|
43
|
+
expect(upstreamEdges(t)).toEqual([])
|
|
44
|
+
expect(rankTree(t).get('repo')).toBe(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('places dependsOn targets upstream and keeps the owned subtree depth', () => {
|
|
48
|
+
const t = tree(
|
|
49
|
+
[node('overlay', 'Kustomization', 'root'), node('base', 'Kustomization'), node('repo', 'GitRepository'), node('deploy'), node('rs', 'ReplicaSet', 'generated'), node('pod', 'Pod', 'generated')],
|
|
50
|
+
[
|
|
51
|
+
{ source: 'overlay', target: 'repo', type: 'source' },
|
|
52
|
+
{ source: 'overlay', target: 'base', type: 'dependsOn' },
|
|
53
|
+
{ source: 'overlay', target: 'deploy', type: 'owns' },
|
|
54
|
+
{ source: 'deploy', target: 'rs', type: 'owns' },
|
|
55
|
+
{ source: 'rs', target: 'pod', type: 'owns' },
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
const rank = rankTree(t)
|
|
59
|
+
expect([rank.get('repo'), rank.get('base')]).toEqual([-1, -1])
|
|
60
|
+
expect([rank.get('deploy'), rank.get('rs'), rank.get('pod')]).toEqual([1, 2, 3])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('terminates on a dependency cycle and never moves the root', () => {
|
|
64
|
+
const t = tree(
|
|
65
|
+
[node('a', 'Kustomization', 'root'), node('b', 'Kustomization'), node('c', 'Kustomization')],
|
|
66
|
+
[
|
|
67
|
+
{ source: 'a', target: 'b', type: 'dependsOn' },
|
|
68
|
+
{ source: 'b', target: 'c', type: 'dependsOn' },
|
|
69
|
+
{ source: 'c', target: 'b', type: 'dependsOn' },
|
|
70
|
+
{ source: 'c', target: 'a', type: 'dependsOn' },
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
const rank = rankTree(t)
|
|
74
|
+
expect(rank.get('a')).toBe(0)
|
|
75
|
+
expect(rank.get('b')).toBeLessThan(0)
|
|
76
|
+
expect(rank.get('c')).toBeLessThan(0)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('renderedEdge', () => {
|
|
81
|
+
it('points a dependency edge into its consumer and leaves ownership alone', () => {
|
|
82
|
+
const upstream = new Set(['repo'])
|
|
83
|
+
expect(renderedEdge({ source: 'ks', target: 'repo', type: 'source' }, upstream)).toEqual({ source: 'repo', target: 'ks' })
|
|
84
|
+
expect(renderedEdge({ source: 'ks', target: 'deploy', type: 'owns' }, upstream)).toEqual({ source: 'ks', target: 'deploy' })
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('upstreamClosure', () => {
|
|
89
|
+
it('follows the chart hop so every preset keeps the whole source chain', () => {
|
|
90
|
+
const t = tree(
|
|
91
|
+
[node('release', 'HelmRelease', 'root'), node('chart', 'HelmChart'), node('repo', 'HelmRepository'), node('deploy')],
|
|
92
|
+
[
|
|
93
|
+
{ source: 'release', target: 'chart', type: 'source' },
|
|
94
|
+
{ source: 'chart', target: 'repo', type: 'source' },
|
|
95
|
+
{ source: 'release', target: 'deploy', type: 'owns' },
|
|
96
|
+
],
|
|
97
|
+
)
|
|
98
|
+
expect([...upstreamClosure(t)].sort()).toEqual(['chart', 'repo'])
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('shortcutEdges', () => {
|
|
103
|
+
it('drops the direct release → repository edge only when the chart path covers it', () => {
|
|
104
|
+
const direct: GitOpsTreeEdge = { source: 'release', target: 'repo', type: 'source' }
|
|
105
|
+
const viaChart: GitOpsTreeEdge[] = [{ source: 'release', target: 'chart', type: 'source' }, { source: 'chart', target: 'repo', type: 'source' }]
|
|
106
|
+
const nodes = [node('release', 'HelmRelease', 'root'), node('chart', 'HelmChart'), node('repo', 'HelmRepository')]
|
|
107
|
+
expect([...shortcutEdges(tree(nodes, [direct, ...viaChart]))]).toEqual([direct])
|
|
108
|
+
// Before the chart is generated the direct edge is the only path.
|
|
109
|
+
expect(shortcutEdges(tree(nodes, [direct])).size).toBe(0)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { GitOpsResourceTree, GitOpsTreeEdge } from '../../../types'
|
|
2
|
+
|
|
3
|
+
// Flux source, chart and dependsOn edges travel consumer → dependency on the
|
|
4
|
+
// wire. A dependency feeds its consumer, so the graph draws it upstream (left
|
|
5
|
+
// of the consumer) with the arrow pointing into the consumer, as Flux's own
|
|
6
|
+
// tooling does. An object the root also manages (a bootstrap Kustomization
|
|
7
|
+
// that owns its GitRepository) keeps its place among the managed resources:
|
|
8
|
+
// ownership wins.
|
|
9
|
+
export function isUpstreamEdgeType(type: GitOpsTreeEdge['type']): boolean {
|
|
10
|
+
return type === 'source' || type === 'dependsOn'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function upstreamEdges(tree: Pick<GitOpsResourceTree, 'edges'>): GitOpsTreeEdge[] {
|
|
14
|
+
const owned = new Set(tree.edges.filter(edge => edge.type === 'owns').map(edge => edge.target))
|
|
15
|
+
return tree.edges.filter(edge => isUpstreamEdgeType(edge.type) && !owned.has(edge.target))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// rankTree places the root at 0, managed resources at positive ranks by
|
|
19
|
+
// ownership depth, and dependencies at negative ranks by their longest
|
|
20
|
+
// dependency path from the root, so a repository that a release names
|
|
21
|
+
// directly and through its generated chart still sits left of the chart.
|
|
22
|
+
// Cycles are bounded: a rank never moves past the node count.
|
|
23
|
+
export function rankTree(tree: GitOpsResourceTree): Map<string, number> {
|
|
24
|
+
const ids = new Set(tree.nodes.map(node => node.id))
|
|
25
|
+
ids.add(tree.root.id)
|
|
26
|
+
const limit = ids.size
|
|
27
|
+
const upstream = upstreamEdges(tree).filter(edge => ids.has(edge.source) && ids.has(edge.target) && edge.target !== tree.root.id)
|
|
28
|
+
|
|
29
|
+
const depth = new Map<string, number>([[tree.root.id, 0]])
|
|
30
|
+
for (let pass = 0; pass < limit; pass++) {
|
|
31
|
+
let changed = false
|
|
32
|
+
for (const edge of upstream) {
|
|
33
|
+
const from = depth.get(edge.source)
|
|
34
|
+
if (from === undefined) continue
|
|
35
|
+
const next = Math.min(from + 1, limit)
|
|
36
|
+
if ((depth.get(edge.target) ?? 0) < next) {
|
|
37
|
+
depth.set(edge.target, next)
|
|
38
|
+
changed = true
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (!changed) break
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rank = new Map<string, number>([[tree.root.id, 0]])
|
|
45
|
+
for (const [id, d] of depth) {
|
|
46
|
+
if (id !== tree.root.id) rank.set(id, -d)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const children = new Map<string, string[]>()
|
|
50
|
+
for (const edge of tree.edges) {
|
|
51
|
+
if (!ids.has(edge.source) || !ids.has(edge.target)) continue
|
|
52
|
+
if (rank.has(edge.target) && rank.get(edge.target)! < 0) continue
|
|
53
|
+
if (rank.has(edge.source) && rank.get(edge.source)! < 0) continue
|
|
54
|
+
children.set(edge.source, [...(children.get(edge.source) ?? []), edge.target])
|
|
55
|
+
}
|
|
56
|
+
const downstream = new Map<string, number>()
|
|
57
|
+
const queue = [{ id: tree.root.id, rank: 0 }]
|
|
58
|
+
let iterations = 0
|
|
59
|
+
const maxIterations = limit * 8 + 64
|
|
60
|
+
while (queue.length > 0 && ++iterations <= maxIterations) {
|
|
61
|
+
const current = queue.shift()!
|
|
62
|
+
const previous = downstream.get(current.id)
|
|
63
|
+
if (previous !== undefined && previous >= current.rank) continue
|
|
64
|
+
downstream.set(current.id, current.rank)
|
|
65
|
+
for (const child of children.get(current.id) ?? []) {
|
|
66
|
+
queue.push({ id: child, rank: current.rank + 1 })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const [id, r] of downstream) {
|
|
70
|
+
if (id !== tree.root.id) rank.set(id, r)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const incoming = new Set(tree.edges.map(edge => edge.target))
|
|
74
|
+
for (const id of ids) {
|
|
75
|
+
if (!rank.has(id)) rank.set(id, incoming.has(id) ? 1 : 0)
|
|
76
|
+
}
|
|
77
|
+
return rank
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// renderedEdge returns the edge as drawn: dependency edges point from the
|
|
81
|
+
// dependency into its consumer.
|
|
82
|
+
export function renderedEdge(edge: GitOpsTreeEdge, upstreamTargets: Set<string>): { source: string; target: string } {
|
|
83
|
+
if (isUpstreamEdgeType(edge.type) && upstreamTargets.has(edge.target)) {
|
|
84
|
+
return { source: edge.target, target: edge.source }
|
|
85
|
+
}
|
|
86
|
+
return { source: edge.source, target: edge.target }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// upstreamClosure returns every dependency reachable from the root, for
|
|
90
|
+
// presets that must keep "where does this come from" in view.
|
|
91
|
+
export function upstreamClosure(tree: GitOpsResourceTree): Set<string> {
|
|
92
|
+
const edges = upstreamEdges(tree)
|
|
93
|
+
const out = new Set<string>()
|
|
94
|
+
const queue = [tree.root.id]
|
|
95
|
+
while (queue.length > 0) {
|
|
96
|
+
const id = queue.shift()!
|
|
97
|
+
for (const edge of edges) {
|
|
98
|
+
if (edge.source !== id || out.has(edge.target) || edge.target === tree.root.id) continue
|
|
99
|
+
out.add(edge.target)
|
|
100
|
+
queue.push(edge.target)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// shortcutEdges returns the dependency edges that a longer dependency path
|
|
107
|
+
// already covers: an inline HelmRelease names its repository directly and
|
|
108
|
+
// through its generated chart, and drawing both puts the shortcut behind the
|
|
109
|
+
// chart card. The data keeps the edge; the drawing doesn't need it.
|
|
110
|
+
export function shortcutEdges(tree: Pick<GitOpsResourceTree, 'edges'>): Set<GitOpsTreeEdge> {
|
|
111
|
+
const edges = upstreamEdges(tree)
|
|
112
|
+
const out = new Set<GitOpsTreeEdge>()
|
|
113
|
+
for (const edge of edges) {
|
|
114
|
+
const seen = new Set<string>([edge.source])
|
|
115
|
+
const queue = edges.filter(e => e !== edge && e.source === edge.source).map(e => e.target)
|
|
116
|
+
while (queue.length > 0) {
|
|
117
|
+
const id = queue.shift()!
|
|
118
|
+
if (id === edge.target) {
|
|
119
|
+
out.add(edge)
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
if (seen.has(id)) continue
|
|
123
|
+
seen.add(id)
|
|
124
|
+
for (const next of edges) if (next.source === id) queue.push(next.target)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out
|
|
128
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildTreeFacets } from '../GitOpsGraphFilterRail'
|
|
3
|
+
import { matchesGitOpsTreeFilters } from './tree-helpers'
|
|
4
|
+
import type { GitOpsResourceTree, GitOpsTreeNode } from '../../../types'
|
|
5
|
+
|
|
6
|
+
const node = (group: string): GitOpsTreeNode => ({ id: group, ref: { kind: 'Service', group, name: 'same', namespace: 'team' }, role: 'generated', tool: 'fluxcd' })
|
|
7
|
+
|
|
8
|
+
describe('GitOps kind filter contract', () => {
|
|
9
|
+
it('uses bare Kubernetes kinds across API groups', () => {
|
|
10
|
+
const nodes = [node(''), node('serving.knative.dev')]
|
|
11
|
+
expect(buildTreeFacets({ nodes } as GitOpsResourceTree).kinds).toEqual([{ name: 'Service', count: 2 }])
|
|
12
|
+
for (const n of nodes) {
|
|
13
|
+
expect(matchesGitOpsTreeFilters(n, { kinds: new Set(['Service']) })).toBe(true)
|
|
14
|
+
expect(matchesGitOpsTreeFilters(n, { kinds: new Set(['Deployment']) })).toBe(false)
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -14,6 +14,12 @@ export function gitOpsFilterSet(values?: Set<string> | string[]): Set<string> |
|
|
|
14
14
|
return set.size > 0 ? set : undefined
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// treeNodeFilterRole is the node's role as the filter rail presents it: a
|
|
18
|
+
// dependency the root consumes but doesn't manage is not "Declared".
|
|
19
|
+
export function treeNodeFilterRole(node: GitOpsTreeNode): string {
|
|
20
|
+
return node.dependency ? 'dependency' : node.role
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsTreeFilters): boolean {
|
|
18
24
|
if (!filters) return true
|
|
19
25
|
const kinds = gitOpsFilterSet(filters.kinds)
|
|
@@ -26,7 +32,7 @@ export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsT
|
|
|
26
32
|
if (namespaces && !namespaces.has(node.ref.namespace || '(cluster)')) return false
|
|
27
33
|
if (sync && !sync.has(node.sync || 'Unknown')) return false
|
|
28
34
|
if (health && !health.has(node.health || 'Unknown')) return false
|
|
29
|
-
if (roles && !roles.has(node
|
|
35
|
+
if (roles && !roles.has(treeNodeFilterRole(node))) return false
|
|
30
36
|
return true
|
|
31
37
|
}
|
|
32
38
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { expect, it, vi } from 'vitest'
|
|
5
|
+
import { IssueRow } from './IssuesView'
|
|
6
|
+
import type { Issue } from './types'
|
|
7
|
+
|
|
8
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
9
|
+
|
|
10
|
+
const condition =
|
|
11
|
+
'If victoria-metrics-operator was uninstalled on purpose (and nothing else should finalize this object), remove the finalizer'
|
|
12
|
+
const kubectl = `kubectl patch 'vmagents.operator.victoriametrics.com' 'stuck' -n 'team' --type=json -p '[]' --dry-run=server`
|
|
13
|
+
|
|
14
|
+
const issue: Issue = {
|
|
15
|
+
id: 'vmagent-stuck',
|
|
16
|
+
severity: 'warning',
|
|
17
|
+
source: 'problem',
|
|
18
|
+
category: 'termination_stuck',
|
|
19
|
+
category_group: 'control_plane',
|
|
20
|
+
grouping_scope: 'unknown',
|
|
21
|
+
group: 'operator.victoriametrics.com',
|
|
22
|
+
kind: 'VMAgent',
|
|
23
|
+
namespace: 'team',
|
|
24
|
+
name: 'stuck',
|
|
25
|
+
reason: 'Terminating stuck',
|
|
26
|
+
cause: 'Deletion has been pending for 2h.',
|
|
27
|
+
action:
|
|
28
|
+
'No recognized controller found for finalizer "apps.victoriametrics.com/finalizer". If victoria-metrics-operator should be running, restore it so it can resume cleanup.',
|
|
29
|
+
conditional_cleanup: {
|
|
30
|
+
condition,
|
|
31
|
+
agent_guidance: 'Not the next step.',
|
|
32
|
+
caution: 'Removing a finalizer skips the cleanup its controller would have done.',
|
|
33
|
+
removals: [
|
|
34
|
+
{
|
|
35
|
+
finalizer: 'apps.victoriametrics.com/finalizer',
|
|
36
|
+
patch_resource: { kind: 'VMAgent', name: 'stuck', dry_run: true },
|
|
37
|
+
kubectl,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
it('keeps a conditional cleanup closed behind its condition, apart from the next step', async () => {
|
|
44
|
+
const element = document.createElement('div')
|
|
45
|
+
const root = createRoot(element)
|
|
46
|
+
await act(async () => {
|
|
47
|
+
root.render(<IssueRow issue={issue} open onToggle={() => {}} />)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const toggle = [...element.querySelectorAll('button')].find((button) => button.textContent?.includes(condition))
|
|
51
|
+
expect(toggle, 'the toggle is labeled with the condition').toBeTruthy()
|
|
52
|
+
expect(toggle!.getAttribute('aria-expanded')).toBe('false')
|
|
53
|
+
const panel = [...element.querySelectorAll<HTMLElement>('[id]')].find((node) => node.id === toggle!.getAttribute('aria-controls'))!
|
|
54
|
+
expect(panel.style.gridTemplateRows).toBe('0fr')
|
|
55
|
+
expect(panel.firstElementChild!.hasAttribute('inert')).toBe(true)
|
|
56
|
+
expect(panel.textContent).toContain(kubectl)
|
|
57
|
+
|
|
58
|
+
const nextStep = [...element.querySelectorAll('section')].find((section) => section.querySelector('h4')?.textContent === 'Next step')
|
|
59
|
+
expect(nextStep?.textContent).toContain('restore it')
|
|
60
|
+
expect(nextStep?.textContent).not.toContain('kubectl')
|
|
61
|
+
expect(nextStep?.textContent).not.toContain('patch_resource')
|
|
62
|
+
expect(nextStep?.contains(toggle!)).toBe(false)
|
|
63
|
+
|
|
64
|
+
await act(async () => {
|
|
65
|
+
toggle!.click()
|
|
66
|
+
})
|
|
67
|
+
expect(toggle!.getAttribute('aria-expanded')).toBe('true')
|
|
68
|
+
expect(panel.style.gridTemplateRows).toBe('1fr')
|
|
69
|
+
expect(panel.firstElementChild!.hasAttribute('inert')).toBe(false)
|
|
70
|
+
|
|
71
|
+
await act(async () => root.unmount())
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('renders no cleanup section when the issue has none', async () => {
|
|
75
|
+
const element = document.createElement('div')
|
|
76
|
+
const root = createRoot(element)
|
|
77
|
+
await act(async () => {
|
|
78
|
+
root.render(<IssueRow issue={{ ...issue, conditional_cleanup: undefined }} open onToggle={() => {}} />)
|
|
79
|
+
})
|
|
80
|
+
expect(element.textContent).not.toContain('Conditional cleanup')
|
|
81
|
+
await act(async () => root.unmount())
|
|
82
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { summarizeSchedulerMessage } from '../resources/resource-utils'
|
|
1
2
|
import { useMemo, useState, type ComponentType, type ReactNode } from 'react';
|
|
2
|
-
import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
|
|
3
|
-
import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
|
|
3
|
+
import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, Eraser, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
|
|
4
|
+
import { CardBody, CardSection, ClusterName, Disclosure, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
|
|
4
5
|
import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse';
|
|
5
6
|
import { Tooltip } from '../ui/Tooltip';
|
|
6
7
|
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
ISSUE_SEVERITY_RAIL_CLASS,
|
|
14
15
|
ISSUE_SEVERITY_SOLID_CLASS,
|
|
15
16
|
ISSUE_SEVERITY_TEXT_CLASS,
|
|
16
|
-
|
|
17
|
+
issueTitle,
|
|
17
18
|
groupBadgeClass,
|
|
18
19
|
groupLabel,
|
|
19
20
|
} from './severity';
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
subjectRef,
|
|
25
26
|
type Issue,
|
|
26
27
|
type IssueAffected,
|
|
28
|
+
type IssueConditionalCleanup,
|
|
27
29
|
type IssueResourceRef,
|
|
28
30
|
type IssueSeverity,
|
|
29
31
|
} from './types';
|
|
@@ -168,6 +170,7 @@ export interface IssueRowProps {
|
|
|
168
170
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
169
171
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
170
172
|
as?: 'li' | 'div';
|
|
173
|
+
compact?: boolean;
|
|
171
174
|
className?: string;
|
|
172
175
|
dimmed?: boolean;
|
|
173
176
|
/** Suppress the "Subject" deep-link in the expanded body — set by hosts that
|
|
@@ -195,6 +198,7 @@ export function IssueRow({
|
|
|
195
198
|
resourceHref,
|
|
196
199
|
onResourceClick,
|
|
197
200
|
as = 'li',
|
|
201
|
+
compact = false,
|
|
198
202
|
className,
|
|
199
203
|
dimmed,
|
|
200
204
|
hideSubject,
|
|
@@ -208,6 +212,7 @@ export function IssueRow({
|
|
|
208
212
|
const cluster = clusterLabel?.(issue);
|
|
209
213
|
const affected = affectedSummary(issue.affected);
|
|
210
214
|
const { headline } = issueMessageParts(issue);
|
|
215
|
+
const schedulingCause = compact && issue.reason === 'Unschedulable' ? summarizeSchedulerMessage(issue.cause || issue.message || headline || '', { plain: true }) : undefined;
|
|
211
216
|
const { panelId, buttonProps } = useDisclosure(open);
|
|
212
217
|
const Container = as;
|
|
213
218
|
const severity = normalizeIssueSeverity(issue.severity);
|
|
@@ -289,7 +294,7 @@ export function IssueRow({
|
|
|
289
294
|
|
|
290
295
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
291
296
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
292
|
-
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{
|
|
297
|
+
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{issueTitle(issue)}</span>
|
|
293
298
|
<span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
|
|
294
299
|
{renderBadges?.(slotCtx)}
|
|
295
300
|
{/* The detector reason rides the title row while COLLAPSED so the
|
|
@@ -297,7 +302,7 @@ export function IssueRow({
|
|
|
297
302
|
cause lives in the WHAT'S WRONG section below, so it fades out
|
|
298
303
|
here (stays mounted — it's the flex-1 filler, so unmounting
|
|
299
304
|
wouldn't reflow anything, but fading avoids the pop). */}
|
|
300
|
-
{issue.reason ? (
|
|
305
|
+
{issue.reason && !schedulingCause ? (
|
|
301
306
|
<span
|
|
302
307
|
aria-hidden={open || undefined}
|
|
303
308
|
className={`min-w-0 flex-1 truncate text-xs text-theme-text-tertiary transition-opacity duration-200 ${open ? 'opacity-0' : 'opacity-100'}`}
|
|
@@ -307,6 +312,7 @@ export function IssueRow({
|
|
|
307
312
|
</span>
|
|
308
313
|
) : null}
|
|
309
314
|
</div>
|
|
315
|
+
{schedulingCause && <div className="break-words text-xs text-theme-text-secondary">{schedulingCause}</div>}
|
|
310
316
|
<div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
311
317
|
<span className={KIND_CHIP_CLASS}>{issue.kind}</span>
|
|
312
318
|
<span className="min-w-0 truncate font-medium text-theme-text-secondary">
|
|
@@ -544,6 +550,8 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
|
|
|
544
550
|
</CardSection>
|
|
545
551
|
) : null}
|
|
546
552
|
|
|
553
|
+
{issue.conditional_cleanup ? <ConditionalCleanup cleanup={issue.conditional_cleanup} /> : null}
|
|
554
|
+
|
|
547
555
|
{rawError ? (
|
|
548
556
|
<CardSection icon={Terminal} label="Raw error">
|
|
549
557
|
<TerminalBlock>{rawError}</TerminalBlock>
|
|
@@ -553,6 +561,33 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
|
|
|
553
561
|
);
|
|
554
562
|
}
|
|
555
563
|
|
|
564
|
+
// A step that is right only under its condition. It stays closed behind the
|
|
565
|
+
// condition so the row's next step remains the default path, and it is not
|
|
566
|
+
// toned as a fix.
|
|
567
|
+
function ConditionalCleanup({ cleanup }: { cleanup: IssueConditionalCleanup }) {
|
|
568
|
+
return (
|
|
569
|
+
<CardSection icon={Eraser} label="Conditional cleanup">
|
|
570
|
+
<Disclosure
|
|
571
|
+
summary={<span className="text-sm leading-relaxed">{cleanup.condition}</span>}
|
|
572
|
+
summaryClassName="items-baseline text-theme-text-primary hover:text-theme-text-secondary"
|
|
573
|
+
>
|
|
574
|
+
<div className="flex flex-col gap-2 pt-2">
|
|
575
|
+
{cleanup.caution ? <p className="text-xs leading-relaxed text-theme-text-secondary">{cleanup.caution}</p> : null}
|
|
576
|
+
{cleanup.removals.map((removal) => (
|
|
577
|
+
<div key={removal.finalizer} className="flex flex-col gap-1.5">
|
|
578
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
579
|
+
Finalizer <code className="inline-code">{removal.finalizer}</code>
|
|
580
|
+
</p>
|
|
581
|
+
<TerminalBlock label="patch_resource · dry run">{JSON.stringify(removal.patch_resource)}</TerminalBlock>
|
|
582
|
+
<TerminalBlock label="kubectl · server dry run">{removal.kubectl}</TerminalBlock>
|
|
583
|
+
</div>
|
|
584
|
+
))}
|
|
585
|
+
</div>
|
|
586
|
+
</Disclosure>
|
|
587
|
+
</CardSection>
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
556
591
|
function changeContextText(change: NonNullable<Issue['change_context']>): string {
|
|
557
592
|
const parts = [change.when ? `Changed ${change.when} ago` : 'Changed', change.what ? change.what.replace(/_/g, ' ') : null, change.evidence].filter(Boolean);
|
|
558
593
|
return parts.join(' · ');
|