@skyhook-io/k8s-ui 1.17.0 → 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/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/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/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 +24 -71
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
- 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 +12 -1
- package/src/components/issues/issues.test.ts +49 -1
- 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.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +17 -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 +302 -109
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- 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 +5 -7
- 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 +7 -15
- 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/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
- package/src/components/resources/renderers/HPARenderer.tsx +56 -16
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
- 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/KedaScaledObjectRenderer.tsx +13 -11
- 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/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +41 -29
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- 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 -20
- 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-knative.ts +18 -0
- package/src/components/resources/resource-utils.ts +82 -6
- 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/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +11 -4
- package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
- package/src/components/shared/index.ts +2 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
- 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 +3 -71
- 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/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +110 -1
- package/src/components/ui/drawer-components.tsx +62 -9
- 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 +43 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +180 -64
- 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 +11 -2
- package/src/types/core.ts +29 -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 +9 -0
- package/src/utils/format.ts +4 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +4 -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 +57 -0
- package/src/utils/navigation.ts +86 -8
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +37 -0
- package/src/utils/resource-hierarchy.ts +15 -7
- package/src/utils/resource-icons.ts +2 -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
|
@@ -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(' · ');
|
|
@@ -106,3 +106,31 @@ it('keeps pod/template evidence in expanded neutral context with navigable witne
|
|
|
106
106
|
expect(expanded).not.toContain('confidence')
|
|
107
107
|
expect(expanded).not.toContain('Caused by')
|
|
108
108
|
})
|
|
109
|
+
|
|
110
|
+
it('wraps a compact Pod scheduling cause separately and retains the full message in details', () => {
|
|
111
|
+
const raw = '0/2 nodes are available: 2 Too many pods. preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.'
|
|
112
|
+
const pod: Issue = { ...issue, kind: 'Pod', reason: 'Unschedulable', cause: raw, message: raw }
|
|
113
|
+
const html = renderToString(<IssueRow issue={pod} compact open onToggle={() => {}} />)
|
|
114
|
+
expect(html).toContain('break-words text-xs text-theme-text-secondary')
|
|
115
|
+
expect(html).toContain('both nodes have reached their Pod limit')
|
|
116
|
+
expect(html).toContain(raw)
|
|
117
|
+
const producerMessage = '2 node(s) insufficient pods (0/2 nodes available)'
|
|
118
|
+
const reported = renderToString(<IssueRow issue={{ ...pod, cause: undefined, message: producerMessage }} compact open onToggle={() => {}} />)
|
|
119
|
+
expect(reported).toContain('both nodes have reached their Pod limit')
|
|
120
|
+
expect(reported).toContain(producerMessage)
|
|
121
|
+
const regular = renderToString(<IssueRow issue={pod} open={false} onToggle={() => {}} />)
|
|
122
|
+
expect(regular).not.toContain('both nodes have reached their Pod limit')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it.each(['Cluster', 'Deployment'])('wraps compact scheduling causes aggregated under %s', (kind) => {
|
|
126
|
+
const raw = '2 node(s) insufficient pods (0/2 nodes available)'
|
|
127
|
+
const aggregated: Issue = { ...issue, kind, reason: 'Unschedulable', cause: raw, message: raw }
|
|
128
|
+
const collapsed = renderToString(<IssueRow issue={aggregated} compact open={false} onToggle={() => {}} />)
|
|
129
|
+
expect(collapsed).toContain('<div class="break-words text-xs text-theme-text-secondary">both nodes have reached their Pod limit</div>')
|
|
130
|
+
expect(collapsed).not.toContain(raw)
|
|
131
|
+
const expanded = renderToString(<IssueRow issue={aggregated} compact open onToggle={() => {}} />)
|
|
132
|
+
expect(expanded).toContain(raw)
|
|
133
|
+
const regular = renderToString(<IssueRow issue={aggregated} open={false} onToggle={() => {}} />)
|
|
134
|
+
expect(regular).toContain('Unschedulable')
|
|
135
|
+
expect(regular).not.toContain('both nodes have reached their Pod limit')
|
|
136
|
+
})
|
|
@@ -8,7 +8,9 @@ export function ResourceIssuesSection({
|
|
|
8
8
|
issues,
|
|
9
9
|
onResourceClick,
|
|
10
10
|
subjectResource,
|
|
11
|
+
compact,
|
|
11
12
|
}: {
|
|
13
|
+
compact?: boolean
|
|
12
14
|
issues: Issue[] | undefined
|
|
13
15
|
/** When provided, related resources in a causal link become clickable. */
|
|
14
16
|
onResourceClick?: (ref: IssueResourceRef) => void
|
|
@@ -36,6 +38,7 @@ export function ResourceIssuesSection({
|
|
|
36
38
|
<IssueRow
|
|
37
39
|
key={key}
|
|
38
40
|
issue={issue}
|
|
41
|
+
compact={compact}
|
|
39
42
|
open={openId === key}
|
|
40
43
|
onToggle={() => setOpenId((cur) => (cur === key ? null : key))}
|
|
41
44
|
onResourceClick={onResourceClick}
|
|
@@ -16,7 +16,7 @@ export {
|
|
|
16
16
|
issueSortAnchor,
|
|
17
17
|
compareIssueSortAnchors,
|
|
18
18
|
} from './types';
|
|
19
|
-
export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
|
|
19
|
+
export type { Issue, IssueConditionalCleanup, IssueFinalizerRemoval, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
|
|
20
20
|
export {
|
|
21
21
|
ISSUE_SEVERITY_LABEL,
|
|
22
22
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
@@ -24,5 +24,7 @@ export {
|
|
|
24
24
|
ISSUE_SEVERITY_FILL_CLASS,
|
|
25
25
|
ISSUE_SEVERITY_RAIL_CLASS,
|
|
26
26
|
categoryLabel,
|
|
27
|
+
issueTitle,
|
|
28
|
+
issueReasonTitle,
|
|
27
29
|
groupLabel,
|
|
28
30
|
} from './severity';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isDeploymentLikeWorkloadKind } from '../../types';
|
|
2
2
|
import type { Issue } from './types';
|
|
3
3
|
|
|
4
|
-
export type IssueTimingDisplayKind = 'creation' | 'regression';
|
|
4
|
+
export type IssueTimingDisplayKind = 'creation' | 'regression' | 'deletion';
|
|
5
5
|
|
|
6
6
|
export interface IssueTimingDisplay {
|
|
7
7
|
kind: IssueTimingDisplayKind;
|
|
@@ -77,6 +77,17 @@ export function issueTiming(issue: Issue): IssueTimingDisplay | null {
|
|
|
77
77
|
const grouped = (coverage?.known ?? 0) + (coverage?.unknown ?? 0) > 1;
|
|
78
78
|
const uncertaintyMeta = onsetUncertaintyMeta(issue);
|
|
79
79
|
const uncertaintyTooltip = onsetUncertaintyTooltip(issue);
|
|
80
|
+
// A stuck deletion is timed from its deletionTimestamp. The creation-vs-healthy
|
|
81
|
+
// classification only says whether the object was deleted soon after it was
|
|
82
|
+
// created, which is not what the age beside this chip measures.
|
|
83
|
+
if (issue.issue_timing_basis === 'deletion' && issue.issue_timing) {
|
|
84
|
+
return {
|
|
85
|
+
kind: 'deletion',
|
|
86
|
+
chip: 'since deletion',
|
|
87
|
+
meta: 'pending since deletion was requested',
|
|
88
|
+
tooltip: 'Timed from when deletion was requested; the object is still present.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
80
91
|
switch (issue.issue_timing) {
|
|
81
92
|
case 'started_after_resource_was_healthy': {
|
|
82
93
|
if (issue.issue_timing_basis === 'owner_condition') {
|
|
@@ -2,7 +2,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest'
|
|
|
2
2
|
import { createElement } from 'react'
|
|
3
3
|
import { renderToString } from 'react-dom/server'
|
|
4
4
|
import { compareIssues, issueSortAnchor, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
|
|
5
|
-
import { categoryLabel, groupBadgeClass, groupLabel } from './severity'
|
|
5
|
+
import { categoryLabel, groupBadgeClass, groupLabel, issueTitle } from './severity'
|
|
6
6
|
import { IssueRow } from './IssuesView'
|
|
7
7
|
import { issueFirstSeenTitle, issueResourceCreatedTitle, issueTiming } from './issue-timing'
|
|
8
8
|
|
|
@@ -83,6 +83,8 @@ describe('category/group label fallbacks', () => {
|
|
|
83
83
|
it('returns the mapped label, else humanizes (server-added category needs no frontend deploy)', () => {
|
|
84
84
|
expect(categoryLabel('crashloop')).toBe('Crash loop')
|
|
85
85
|
expect(categoryLabel('some_new_future_category')).toBe('Some new future category')
|
|
86
|
+
expect(issueTitle({ category: 'backup_failed', reason: 'CNPGWALArchivingFailing' })).toBe('WAL archiving failing')
|
|
87
|
+
expect(issueTitle({ category: 'backup_failed', reason: 'BackupFailed' })).toBe('Backup failed')
|
|
86
88
|
})
|
|
87
89
|
it('humanizes an unmapped group', () => {
|
|
88
90
|
expect(groupLabel('runtime')).toBe('Runtime')
|
|
@@ -137,6 +139,33 @@ describe('IssueRow', () => {
|
|
|
137
139
|
expect(html).toContain('after healthy')
|
|
138
140
|
})
|
|
139
141
|
|
|
142
|
+
it('labels a stuck deletion by how long deletion has been pending', () => {
|
|
143
|
+
vi.useFakeTimers()
|
|
144
|
+
vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
|
|
145
|
+
|
|
146
|
+
const html = renderToString(createElement(IssueRow, {
|
|
147
|
+
issue: mk({
|
|
148
|
+
id: 'terminating',
|
|
149
|
+
kind: 'VMAgent',
|
|
150
|
+
group: 'operator.victoriametrics.com',
|
|
151
|
+
category: 'termination_stuck',
|
|
152
|
+
category_group: 'control_plane',
|
|
153
|
+
reason: 'Terminating stuck',
|
|
154
|
+
first_seen: '2026-06-30T11:30:00Z',
|
|
155
|
+
issue_timing: 'started_at_resource_creation',
|
|
156
|
+
issue_timing_basis: 'deletion',
|
|
157
|
+
}),
|
|
158
|
+
open: true,
|
|
159
|
+
onToggle: () => undefined,
|
|
160
|
+
}))
|
|
161
|
+
|
|
162
|
+
expect(html).toContain('30m')
|
|
163
|
+
expect(html).toContain('since deletion')
|
|
164
|
+
expect(html).toContain('pending since deletion was requested')
|
|
165
|
+
expect(html).not.toContain('since creation')
|
|
166
|
+
expect(html).not.toContain('first reconciliation')
|
|
167
|
+
})
|
|
168
|
+
|
|
140
169
|
it('uses the category-group chip class in the queue row', () => {
|
|
141
170
|
const html = renderToString(createElement(IssueRow, {
|
|
142
171
|
issue: mk({ category_group: 'control_plane', kind: 'Node' }),
|
|
@@ -394,6 +423,25 @@ describe('issueTiming', () => {
|
|
|
394
423
|
})
|
|
395
424
|
})
|
|
396
425
|
|
|
426
|
+
it('times stuck deletions from the deletion request, however soon after creation it came', () => {
|
|
427
|
+
for (const timing of ['started_at_resource_creation', 'started_after_resource_was_healthy'] as const) {
|
|
428
|
+
for (const kind of ['Pod', 'VMAgent']) {
|
|
429
|
+
const display = issueTiming(mk({
|
|
430
|
+
kind,
|
|
431
|
+
category: 'termination_stuck',
|
|
432
|
+
issue_timing: timing,
|
|
433
|
+
issue_timing_basis: 'deletion',
|
|
434
|
+
}))
|
|
435
|
+
expect(display).toMatchObject({
|
|
436
|
+
kind: 'deletion',
|
|
437
|
+
chip: 'since deletion',
|
|
438
|
+
meta: 'pending since deletion was requested',
|
|
439
|
+
})
|
|
440
|
+
expect(`${display?.chip} ${display?.meta} ${display?.tooltip}`).not.toMatch(/creation|healthy|deploy/i)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
|
|
397
445
|
it('describes direct after-healthy timing without coupling it to first_seen', () => {
|
|
398
446
|
const display = issueTiming(mk({
|
|
399
447
|
first_seen: '2026-06-30T10:00:00Z',
|