@skyhook-io/k8s-ui 1.8.10 → 1.8.12
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/package.json +7 -6
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +697 -0
- package/src/components/applications/ApplicationDetail.tsx +3269 -474
- package/src/components/applications/ApplicationsView.tsx +62 -10
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +2 -3
- package/src/components/checks/severity.ts +14 -10
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +13 -6
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
- package/src/components/gitops/insights/index.ts +1 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
- package/src/components/gitops/insights/insights-helpers.ts +74 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
- package/src/components/issues/IssuesView.tsx +91 -52
- package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
- package/src/components/issues/ResourceIssuesSection.tsx +46 -144
- package/src/components/issues/index.ts +3 -0
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +102 -1
- package/src/components/issues/severity.ts +3 -0
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
- package/src/components/resources/ResourcesSidebar.tsx +46 -46
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +233 -51
- package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
- package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
- package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-argo.test.ts +18 -1
- package/src/components/resources/resource-utils-argo.ts +8 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +142 -111
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/shared/UnifiedDiff.tsx +51 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/timeline/TimelineList.tsx +375 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
- package/src/components/topology/TopologyControls.tsx +3 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +149 -90
- package/src/components/topology/TopologyOverlayBar.tsx +30 -0
- package/src/components/topology/TopologySearch.tsx +25 -17
- package/src/components/topology/index.ts +2 -0
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +6 -1
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/SelectMenu.tsx +81 -0
- package/src/components/ui/drawer-components.tsx +48 -32
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +11 -4
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2706 -475
- package/src/components/workload/index.ts +1 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +2 -1
- package/src/types/core.ts +57 -4
- package/src/types/gitops-insights.ts +69 -1
- package/src/types/gitops.ts +3 -3
- package/src/utils/application-history.test.ts +538 -0
- package/src/utils/application-history.ts +257 -0
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +201 -1
- package/src/utils/applications.ts +1182 -393
- package/src/utils/index.ts +3 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +1024 -1
- package/src/utils/resource-hierarchy.ts +925 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { SEVERITY_DOT } from '../../../utils/badge-colors'
|
|
3
|
-
import {
|
|
3
|
+
import type { GitOpsChange } from '../../../types'
|
|
4
|
+
import {
|
|
5
|
+
changeMatchesFacets,
|
|
6
|
+
changeMatchesSearch,
|
|
7
|
+
compactSource,
|
|
8
|
+
entryTone,
|
|
9
|
+
gitopsToSeverity,
|
|
10
|
+
healthStatusRank,
|
|
11
|
+
isArgoResourceSyncEligible,
|
|
12
|
+
messageToPhase,
|
|
13
|
+
phaseToTone,
|
|
14
|
+
resourceStatusCounts,
|
|
15
|
+
syncStatusRank,
|
|
16
|
+
} from './insights-helpers'
|
|
17
|
+
|
|
18
|
+
function change(partial: Partial<GitOpsChange> & { ref: GitOpsChange['ref'] }): GitOpsChange {
|
|
19
|
+
return { category: 'Unknown', hasDesired: true, hasLive: true, partial: false, ...partial }
|
|
20
|
+
}
|
|
4
21
|
|
|
5
22
|
describe('gitopsToSeverity', () => {
|
|
6
23
|
it.each([
|
|
@@ -96,3 +113,87 @@ describe('compactSource', () => {
|
|
|
96
113
|
expect(compactSource('')).toBe('')
|
|
97
114
|
})
|
|
98
115
|
})
|
|
116
|
+
|
|
117
|
+
describe('changeMatchesSearch', () => {
|
|
118
|
+
const c = change({ ref: { kind: 'Deployment', name: 'guestbook-ui', namespace: 'demo', group: 'apps' } })
|
|
119
|
+
it('matches on kind, name, namespace, and group (case-insensitive)', () => {
|
|
120
|
+
expect(changeMatchesSearch(c, 'deploy')).toBe(true)
|
|
121
|
+
expect(changeMatchesSearch(c, 'GUESTBOOK')).toBe(true)
|
|
122
|
+
expect(changeMatchesSearch(c, 'demo')).toBe(true)
|
|
123
|
+
expect(changeMatchesSearch(c, 'apps')).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
it('empty/whitespace query matches everything', () => {
|
|
126
|
+
expect(changeMatchesSearch(c, '')).toBe(true)
|
|
127
|
+
expect(changeMatchesSearch(c, ' ')).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
it('non-matching query is filtered out', () => {
|
|
130
|
+
expect(changeMatchesSearch(c, 'nomatch')).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('changeMatchesFacets', () => {
|
|
135
|
+
const outOfSync = change({ ref: { kind: 'Service', name: 'a' }, sync: 'OutOfSync', health: 'Healthy' })
|
|
136
|
+
const degraded = change({ ref: { kind: 'Deployment', name: 'b' }, sync: 'Synced', health: 'Degraded' })
|
|
137
|
+
const missing = change({ ref: { kind: 'Secret', name: 'c' }, sync: 'OutOfSync', health: 'Missing' })
|
|
138
|
+
const healthy = change({ ref: { kind: 'ConfigMap', name: 'd' }, sync: 'Synced', health: 'Healthy' })
|
|
139
|
+
|
|
140
|
+
it('empty facet set matches everything', () => {
|
|
141
|
+
const none = new Set<never>()
|
|
142
|
+
expect(changeMatchesFacets(outOfSync, none)).toBe(true)
|
|
143
|
+
expect(changeMatchesFacets(healthy, none)).toBe(true)
|
|
144
|
+
})
|
|
145
|
+
it('outOfSync keys off sync status', () => {
|
|
146
|
+
const f = new Set(['outOfSync'] as const)
|
|
147
|
+
expect(changeMatchesFacets(outOfSync, f)).toBe(true)
|
|
148
|
+
expect(changeMatchesFacets(degraded, f)).toBe(false)
|
|
149
|
+
})
|
|
150
|
+
it('degraded/missing key off health status', () => {
|
|
151
|
+
expect(changeMatchesFacets(degraded, new Set(['degraded'] as const))).toBe(true)
|
|
152
|
+
expect(changeMatchesFacets(missing, new Set(['missing'] as const))).toBe(true)
|
|
153
|
+
expect(changeMatchesFacets(healthy, new Set(['degraded'] as const))).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
it('multiple facets union (OR)', () => {
|
|
156
|
+
const f = new Set(['degraded', 'missing'] as const)
|
|
157
|
+
expect(changeMatchesFacets(degraded, f)).toBe(true)
|
|
158
|
+
expect(changeMatchesFacets(missing, f)).toBe(true)
|
|
159
|
+
expect(changeMatchesFacets(outOfSync, f)).toBe(false)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('resourceStatusCounts', () => {
|
|
164
|
+
it('counts each facet independently (a resource can be both OutOfSync and Missing)', () => {
|
|
165
|
+
const counts = resourceStatusCounts([
|
|
166
|
+
change({ ref: { kind: 'Service', name: 'a' }, sync: 'OutOfSync', health: 'Healthy' }),
|
|
167
|
+
change({ ref: { kind: 'Secret', name: 'c' }, sync: 'OutOfSync', health: 'Missing' }),
|
|
168
|
+
change({ ref: { kind: 'Deployment', name: 'b' }, sync: 'Synced', health: 'Degraded' }),
|
|
169
|
+
change({ ref: { kind: 'ConfigMap', name: 'd' }, sync: 'Synced', health: 'Healthy' }),
|
|
170
|
+
])
|
|
171
|
+
expect(counts).toEqual({ outOfSync: 2, degraded: 1, missing: 1 })
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('isArgoResourceSyncEligible', () => {
|
|
176
|
+
const outOfSync = change({ ref: { group: 'apps', kind: 'Deployment', namespace: 'demo', name: 'web' }, sync: 'OutOfSync' })
|
|
177
|
+
|
|
178
|
+
it('allows declared OutOfSync and Missing resources', () => {
|
|
179
|
+
expect(isArgoResourceSyncEligible(outOfSync, true)).toBe(true)
|
|
180
|
+
expect(isArgoResourceSyncEligible(change({ ref: { kind: 'Service', name: 'missing' }, health: 'Missing' }), true)).toBe(true)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('excludes generated, hook, and already-synced resources', () => {
|
|
184
|
+
expect(isArgoResourceSyncEligible(outOfSync, false)).toBe(false)
|
|
185
|
+
expect(isArgoResourceSyncEligible(outOfSync, true, 'PreSync')).toBe(false)
|
|
186
|
+
expect(isArgoResourceSyncEligible(change({ ref: { kind: 'Service', name: 'ready' }, sync: 'Synced', health: 'Healthy' }), true)).toBe(false)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('syncStatusRank / healthStatusRank', () => {
|
|
191
|
+
it('ascending sync rank surfaces OutOfSync before Synced', () => {
|
|
192
|
+
expect(syncStatusRank('OutOfSync')).toBeLessThan(syncStatusRank('Synced'))
|
|
193
|
+
expect(syncStatusRank('OutOfSync')).toBeLessThan(syncStatusRank('Unknown'))
|
|
194
|
+
})
|
|
195
|
+
it('ascending health rank surfaces Missing/Degraded before Healthy', () => {
|
|
196
|
+
expect(healthStatusRank('Missing')).toBeLessThan(healthStatusRank('Degraded'))
|
|
197
|
+
expect(healthStatusRank('Degraded')).toBeLessThan(healthStatusRank('Healthy'))
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// GitOpsInsightViews.tsx so they can be unit-tested without importing JSX.
|
|
3
3
|
|
|
4
4
|
import { SEVERITY_DOT, type Severity } from '../../../utils/badge-colors'
|
|
5
|
-
import type { GitOpsHistoryItem } from '../../../types'
|
|
5
|
+
import type { GitOpsChange, GitOpsHistoryItem } from '../../../types'
|
|
6
6
|
import type { SyncStatus, GitOpsHealthStatus } from '../../../types/gitops'
|
|
7
7
|
|
|
8
8
|
const SYNC_STATUS_SET = new Set<SyncStatus>(['Synced', 'OutOfSync', 'Reconciling', 'Unknown'])
|
|
@@ -23,6 +23,79 @@ export function normalizeHealthStatus(value: string | undefined | null): GitOpsH
|
|
|
23
23
|
return 'Unknown'
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// --- Resources table: filtering + sorting -------------------------------
|
|
27
|
+
// Pure predicates + rank functions backing the Resources list's search box,
|
|
28
|
+
// status facets, and sortable columns. Kept here (not inline in the view) so
|
|
29
|
+
// the sort/filter behavior is unit-testable without rendering.
|
|
30
|
+
|
|
31
|
+
// The status facets an operator filters the Resources list by — the three
|
|
32
|
+
// states worth isolating on a broadly-drifted app. (These replaced the
|
|
33
|
+
// per-resource OutOfSync "issues" that used to restate the whole table.)
|
|
34
|
+
export type ResourceStatusFacet = 'outOfSync' | 'degraded' | 'missing'
|
|
35
|
+
|
|
36
|
+
export type ResourceSortKey = 'order' | 'name' | 'sync' | 'health'
|
|
37
|
+
|
|
38
|
+
// changeSync/changeHealth normalize a change's raw backend strings onto the
|
|
39
|
+
// badge unions. `sync` falls back to `category` to match how ChangeRow and
|
|
40
|
+
// the sync badge resolve it.
|
|
41
|
+
export function changeSync(change: GitOpsChange): SyncStatus {
|
|
42
|
+
return normalizeSyncStatus(change.sync ?? change.category)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
|
|
46
|
+
return normalizeHealthStatus(change.health)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isArgoResourceSyncEligible(change: GitOpsChange, declared: boolean, hook?: string): boolean {
|
|
50
|
+
if (!declared || hook || change.hookPhase) return false
|
|
51
|
+
return changeSync(change) === 'OutOfSync' || changeHealth(change) === 'Missing' || change.category === 'Missing'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Rank sync/health so an ascending sort surfaces the states an operator cares
|
|
55
|
+
// about first (drift, then failures). Mirrors the GitOps application table's
|
|
56
|
+
// syncRank/healthRank so the two tables order status the same way.
|
|
57
|
+
export function syncStatusRank(sync: SyncStatus): number {
|
|
58
|
+
return ({ OutOfSync: 0, Reconciling: 1, Unknown: 2, Synced: 3 } as Record<SyncStatus, number>)[sync]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function healthStatusRank(health: GitOpsHealthStatus): number {
|
|
62
|
+
return ({ Missing: 0, Degraded: 1, Progressing: 2, Unknown: 3, Suspended: 4, Healthy: 5 } as Record<GitOpsHealthStatus, number>)[health]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function changeMatchesSearch(change: GitOpsChange, query: string): boolean {
|
|
66
|
+
const q = query.trim().toLowerCase()
|
|
67
|
+
if (!q) return true
|
|
68
|
+
const { kind, name, namespace, group } = change.ref
|
|
69
|
+
return [kind, name, namespace, group].some((s) => s?.toLowerCase().includes(q))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Union match: a change passes when it satisfies ANY active facet (empty set
|
|
73
|
+
// = no filter). OutOfSync keys off sync status; Degraded/Missing off health.
|
|
74
|
+
export function changeMatchesFacets(change: GitOpsChange, facets: Set<ResourceStatusFacet>): boolean {
|
|
75
|
+
if (facets.size === 0) return true
|
|
76
|
+
if (facets.has('outOfSync') && changeSync(change) === 'OutOfSync') return true
|
|
77
|
+
if (facets.has('degraded') && changeHealth(change) === 'Degraded') return true
|
|
78
|
+
if (facets.has('missing') && changeHealth(change) === 'Missing') return true
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ResourceStatusCounts {
|
|
83
|
+
outOfSync: number
|
|
84
|
+
degraded: number
|
|
85
|
+
missing: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resourceStatusCounts(changes: GitOpsChange[]): ResourceStatusCounts {
|
|
89
|
+
const counts: ResourceStatusCounts = { outOfSync: 0, degraded: 0, missing: 0 }
|
|
90
|
+
for (const c of changes) {
|
|
91
|
+
if (changeSync(c) === 'OutOfSync') counts.outOfSync++
|
|
92
|
+
const health = changeHealth(c)
|
|
93
|
+
if (health === 'Degraded') counts.degraded++
|
|
94
|
+
if (health === 'Missing') counts.missing++
|
|
95
|
+
}
|
|
96
|
+
return counts
|
|
97
|
+
}
|
|
98
|
+
|
|
26
99
|
// Map GitOps-flavored vocabulary (Argo/Flux phase strings, insight Issue
|
|
27
100
|
// severities) onto the canonical Severity tokens used by SEVERITY_BADGE /
|
|
28
101
|
// SEVERITY_TEXT / SEVERITY_DOT. Centralizing this keeps call sites from
|
|
@@ -17,12 +17,14 @@ import {
|
|
|
17
17
|
import '@xyflow/react/dist/style.css'
|
|
18
18
|
import { AlertTriangle, ChevronRight, Maximize, Search, X } from 'lucide-react'
|
|
19
19
|
import { PaneLoader } from '../../ui/PaneLoader'
|
|
20
|
+
import { Input } from '../../ui/Input'
|
|
20
21
|
import { clsx } from 'clsx'
|
|
21
22
|
|
|
22
23
|
import type { GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
|
|
23
24
|
import { displayKind } from '../../../types'
|
|
24
25
|
import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
|
|
25
26
|
import { formatCompactAge } from '../../../utils/format'
|
|
27
|
+
import { getTopologyIcon } from '../../../utils/resource-icons'
|
|
26
28
|
import { Tooltip } from '../../ui/Tooltip'
|
|
27
29
|
import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
|
|
28
30
|
|
|
@@ -252,7 +254,7 @@ function GitOpsTreeToolbar({
|
|
|
252
254
|
</div>
|
|
253
255
|
<div className="flex items-center gap-1 rounded-lg border border-theme-border bg-theme-surface/90 px-2 py-1.5 backdrop-blur">
|
|
254
256
|
<Search className="h-3.5 w-3.5 text-theme-text-tertiary" />
|
|
255
|
-
<
|
|
257
|
+
<Input
|
|
256
258
|
value={query}
|
|
257
259
|
onChange={(event) => onQueryChange(event.target.value)}
|
|
258
260
|
placeholder="Find node..."
|
|
@@ -549,6 +551,7 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
|
|
|
549
551
|
const gitopsTool = gitopsToolForNode(node)
|
|
550
552
|
const chips = buildChips(node)
|
|
551
553
|
const dim = getNodeDimensions(node)
|
|
554
|
+
const KindIcon = getTopologyIcon(kind)
|
|
552
555
|
|
|
553
556
|
return (
|
|
554
557
|
<>
|
|
@@ -584,7 +587,7 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
|
|
|
584
587
|
>
|
|
585
588
|
<div className="px-3 py-2.5">
|
|
586
589
|
<div className="mb-1 flex items-center gap-1.5">
|
|
587
|
-
<
|
|
590
|
+
<KindIcon className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" aria-hidden />
|
|
588
591
|
<span className="truncate text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
589
592
|
{node.role === 'group' ? displayKind((node.data?.groupedKind as string) || kind) : displayKind(kind)}
|
|
590
593
|
</span>
|
|
@@ -655,6 +658,11 @@ function isNodeTerminating(node: GitOpsTreeNode): boolean {
|
|
|
655
658
|
// treatment around them (tool icon overlay, "→" affordance, stronger
|
|
656
659
|
// border) advertises that they're not just leaves.
|
|
657
660
|
function gitopsToolForNode(node: GitOpsTreeNode): 'argocd' | 'fluxcd' | undefined {
|
|
661
|
+
// The root node IS the resource this detail page is already showing — it is
|
|
662
|
+
// the subject, not a portal to another page. Never give it the portal
|
|
663
|
+
// treatment (tool badge, chevron, click-to-dive ring): clicking the thing
|
|
664
|
+
// you're already viewing would just stack an identical page onto itself.
|
|
665
|
+
if (node.role === 'root') return undefined
|
|
658
666
|
const t = node.data?.gitopsTool
|
|
659
667
|
if (t === 'argocd' || t === 'fluxcd') return t
|
|
660
668
|
return undefined
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
|
|
2
2
|
import { AlertOctagon, AlertTriangle, ArrowRight, ChevronRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
|
|
3
3
|
import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
|
|
4
|
+
import { Tooltip } from '../ui/Tooltip';
|
|
4
5
|
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
|
|
5
6
|
import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
|
|
7
|
+
import { issueTiming } from './issue-timing';
|
|
6
8
|
import {
|
|
7
9
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
8
10
|
ISSUE_SEVERITY_HEADER_BAND_CLASS,
|
|
@@ -60,12 +62,15 @@ export interface IssuesViewProps {
|
|
|
60
62
|
clusterLabel?: (issue: Issue) => string | undefined;
|
|
61
63
|
/** Empty-state CTA shown when there's no data. */
|
|
62
64
|
emptyAction?: ReactNode;
|
|
65
|
+
/** Per-row trailing action, rendered after the severity badge — e.g. the
|
|
66
|
+
* "Diagnose with AI" button in OSS. Omit to render no per-row action. */
|
|
67
|
+
renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
// The queue list. Filtering/faceting is the host page's job (FleetPageShell on
|
|
66
71
|
// the hub, a thin wrapper in OSS) — this renders the rows + the healthy /
|
|
67
72
|
// no-data terminal states only.
|
|
68
|
-
export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction }: IssuesViewProps) {
|
|
73
|
+
export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction, renderActions }: IssuesViewProps) {
|
|
69
74
|
// Single-open accordion: opening a row collapses the previous one, so the
|
|
70
75
|
// queue stays scannable and you never lose your place to a wall of expansions.
|
|
71
76
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
@@ -104,6 +109,7 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
|
|
|
104
109
|
onToggle={() => setOpenId((cur) => (cur === rowKey ? null : rowKey))}
|
|
105
110
|
resourceHref={resourceHref}
|
|
106
111
|
onResourceClick={onResourceClick}
|
|
112
|
+
renderActions={renderActions}
|
|
107
113
|
/>
|
|
108
114
|
);
|
|
109
115
|
})}
|
|
@@ -126,6 +132,10 @@ export interface IssueRowProps {
|
|
|
126
132
|
as?: 'li' | 'div';
|
|
127
133
|
className?: string;
|
|
128
134
|
dimmed?: boolean;
|
|
135
|
+
/** Suppress the "Subject" deep-link in the expanded body — set by hosts that
|
|
136
|
+
* embed the row under the very resource that IS the subject (the drawer header
|
|
137
|
+
* already names it), so it doesn't echo back redundantly. */
|
|
138
|
+
hideSubject?: boolean;
|
|
129
139
|
renderBadges?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
130
140
|
renderMeta?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
131
141
|
renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
|
|
@@ -142,6 +152,7 @@ export function IssueRow({
|
|
|
142
152
|
as = 'li',
|
|
143
153
|
className,
|
|
144
154
|
dimmed,
|
|
155
|
+
hideSubject,
|
|
145
156
|
renderBadges,
|
|
146
157
|
renderMeta,
|
|
147
158
|
renderActions,
|
|
@@ -155,6 +166,37 @@ export function IssueRow({
|
|
|
155
166
|
const severity = normalizeIssueSeverity(issue.severity);
|
|
156
167
|
const SeverityIcon = ISSUE_SEVERITY_ICON[severity];
|
|
157
168
|
const slotCtx = { issue, open };
|
|
169
|
+
const timing = issueTiming(issue);
|
|
170
|
+
|
|
171
|
+
// Severity pill + age + timing chip. Rendered in two positions the container
|
|
172
|
+
// query toggles: inline at the row's right edge on a wide container, and on a
|
|
173
|
+
// line of its own below the resource line once the row is too narrow to hold
|
|
174
|
+
// it beside the title — so the title never has to truncate to make room.
|
|
175
|
+
const metaChips = (wrapperClass: string) => (
|
|
176
|
+
<div className={`items-center gap-3 ${wrapperClass}`}>
|
|
177
|
+
<span className={`badge-sm shrink-0 px-2.5 py-0.5 text-xs font-semibold ${open ? ISSUE_SEVERITY_SOLID_CLASS[severity] : ISSUE_SEVERITY_BADGE_CLASS[severity]}`}>
|
|
178
|
+
{ISSUE_SEVERITY_LABEL[severity]}
|
|
179
|
+
</span>
|
|
180
|
+
{issue.first_seen ? (
|
|
181
|
+
<>
|
|
182
|
+
<Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
|
|
183
|
+
<time
|
|
184
|
+
dateTime={issue.first_seen}
|
|
185
|
+
className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
|
|
186
|
+
>
|
|
187
|
+
<Clock className="h-3 w-3" aria-hidden />
|
|
188
|
+
{formatCompactAge(issue.first_seen)}
|
|
189
|
+
</time>
|
|
190
|
+
</Tooltip>
|
|
191
|
+
{timing ? (
|
|
192
|
+
<Tooltip content={timing.tooltip} delay={200}>
|
|
193
|
+
<span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
|
|
194
|
+
</Tooltip>
|
|
195
|
+
) : null}
|
|
196
|
+
</>
|
|
197
|
+
) : null}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
158
200
|
|
|
159
201
|
useEffect(() => {
|
|
160
202
|
if (open) {
|
|
@@ -199,13 +241,13 @@ export function IssueRow({
|
|
|
199
241
|
onToggle();
|
|
200
242
|
}
|
|
201
243
|
}}
|
|
202
|
-
className={`group flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
|
|
244
|
+
className={`group @container/issue flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
|
|
203
245
|
>
|
|
204
246
|
<SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
|
|
205
247
|
|
|
206
248
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
207
249
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
208
|
-
<span className="
|
|
250
|
+
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
|
|
209
251
|
<span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
|
|
210
252
|
{renderBadges?.(slotCtx)}
|
|
211
253
|
{/* The detector reason rides the title row while COLLAPSED so the
|
|
@@ -248,35 +290,26 @@ export function IssueRow({
|
|
|
248
290
|
<span aria-hidden>·</span>
|
|
249
291
|
{/* Non-interactive signal (the header is the toggle — a nested
|
|
250
292
|
button would be invalid); the clickable link lives in the body. */}
|
|
251
|
-
<
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
293
|
+
<Tooltip content={confidenceTitle(issue.incident_parent.confidence ?? '')} delay={200} wrapperClassName="min-w-0">
|
|
294
|
+
<span className="truncate text-theme-text-tertiary">
|
|
295
|
+
↳ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}{' '}
|
|
296
|
+
<span className="font-medium text-theme-text-secondary">{issue.incident_parent.ref.kind} / {issue.incident_parent.ref.name}</span>
|
|
297
|
+
</span>
|
|
298
|
+
</Tooltip>
|
|
255
299
|
</>
|
|
256
300
|
) : null}
|
|
257
301
|
{renderMeta?.(slotCtx)}
|
|
258
302
|
</div>
|
|
303
|
+
{/* Narrow container: the meta chips drop to their own line here rather
|
|
304
|
+
than stealing width from the title on the row above. Hidden once the
|
|
305
|
+
row is wide enough to hold them inline in the right cluster. */}
|
|
306
|
+
{metaChips('flex @2xl/issue:hidden')}
|
|
259
307
|
</div>
|
|
260
308
|
|
|
261
|
-
{/* Right cluster —
|
|
309
|
+
{/* Right cluster — the always-visible affordances (per-row action +
|
|
310
|
+
chevron) plus, on a wide container, the inline meta chips. */}
|
|
262
311
|
<div className="flex shrink-0 items-center gap-3">
|
|
263
|
-
|
|
264
|
-
{ISSUE_SEVERITY_LABEL[severity]}
|
|
265
|
-
</span>
|
|
266
|
-
{/* Age chip: keep recency visible; the deployment-start signal rides as a secondary tag. */}
|
|
267
|
-
{issue.first_seen ? (
|
|
268
|
-
<time
|
|
269
|
-
dateTime={issue.first_seen}
|
|
270
|
-
title={ageTitle(issue)}
|
|
271
|
-
className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
|
|
272
|
-
>
|
|
273
|
-
<Clock className="h-3 w-3" aria-hidden />
|
|
274
|
-
{formatCompactAge(issue.first_seen)}
|
|
275
|
-
{issue.issue_timing === 'started_at_resource_creation' ? (
|
|
276
|
-
<span className="badge-sm ml-1 text-[10px] text-theme-text-secondary">since deploy</span>
|
|
277
|
-
) : null}
|
|
278
|
-
</time>
|
|
279
|
-
) : null}
|
|
312
|
+
{metaChips('hidden @2xl/issue:flex')}
|
|
280
313
|
{renderActions?.(slotCtx)}
|
|
281
314
|
<ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
|
|
282
315
|
</div>
|
|
@@ -302,9 +335,11 @@ export function IssueRow({
|
|
|
302
335
|
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
303
336
|
{incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}
|
|
304
337
|
{issue.incident_parent.confidence ? (
|
|
305
|
-
<
|
|
306
|
-
|
|
307
|
-
|
|
338
|
+
<Tooltip content={confidenceTitle(issue.incident_parent.confidence)} delay={200}>
|
|
339
|
+
<span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary">
|
|
340
|
+
{issue.incident_parent.confidence} confidence
|
|
341
|
+
</span>
|
|
342
|
+
</Tooltip>
|
|
308
343
|
) : null}
|
|
309
344
|
</h4>
|
|
310
345
|
<ul className="flex flex-col gap-px">
|
|
@@ -313,7 +348,7 @@ export function IssueRow({
|
|
|
313
348
|
</section>
|
|
314
349
|
) : null}
|
|
315
350
|
<DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
316
|
-
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
351
|
+
<AffectedResources issue={issue} hideSubject={hideSubject} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
317
352
|
</div>
|
|
318
353
|
</div>
|
|
319
354
|
</div>
|
|
@@ -343,6 +378,7 @@ function Diagnosis({ issue }: { issue: Issue }) {
|
|
|
343
378
|
// present, else headline+detail) rather than a string that may never appear.
|
|
344
379
|
const visibleMessage = [headline, detail].filter(Boolean).join(' ');
|
|
345
380
|
const shownText = issue.cause ?? visibleMessage;
|
|
381
|
+
const timing = issueTiming(issue);
|
|
346
382
|
const rawError = [
|
|
347
383
|
...new Set(
|
|
348
384
|
[issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
|
|
@@ -359,15 +395,13 @@ function Diagnosis({ issue }: { issue: Issue }) {
|
|
|
359
395
|
else if (issue.operation_retry_count) meta.push('Retrying');
|
|
360
396
|
if (issue.operation_retry_count) meta.push(`retried ${issue.operation_retry_count}×`);
|
|
361
397
|
if (crash) meta.push(crash);
|
|
398
|
+
if (timing) {
|
|
399
|
+
meta.push(timing.meta);
|
|
400
|
+
} else if (issue.first_seen) {
|
|
401
|
+
meta.push(`started ${formatRelativeAgeTime(issue.first_seen)}`);
|
|
402
|
+
}
|
|
362
403
|
if (issue.first_seen) {
|
|
363
|
-
meta.push(
|
|
364
|
-
issue.issue_timing === 'started_at_resource_creation'
|
|
365
|
-
? 'present since deployment'
|
|
366
|
-
: issue.issue_timing === 'started_after_resource_was_healthy'
|
|
367
|
-
? `started ${formatRelativeAgeTime(issue.first_seen)} · was previously healthy`
|
|
368
|
-
: `started ${formatRelativeAgeTime(issue.first_seen)}`,
|
|
369
|
-
);
|
|
370
|
-
if (issue.last_seen && issue.issue_timing !== 'started_at_resource_creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
404
|
+
if (issue.last_seen && timing?.kind !== 'creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
371
405
|
}
|
|
372
406
|
if (issue.change_context) meta.push(changeContextText(issue.change_context));
|
|
373
407
|
|
|
@@ -448,12 +482,11 @@ function DiagnosticContext({
|
|
|
448
482
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
449
483
|
<span className="shrink-0 text-xs font-medium text-theme-text-secondary">{diagnosticFactLabel(fact.type)}</span>
|
|
450
484
|
{fact.confidence ? (
|
|
451
|
-
<
|
|
452
|
-
className="shrink-0 badge-sm text-[10px] text-theme-text-tertiary"
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
</span>
|
|
485
|
+
<Tooltip content={confidenceTitle(fact.confidence)} delay={200}>
|
|
486
|
+
<span className="shrink-0 badge-sm text-[10px] text-theme-text-tertiary">
|
|
487
|
+
{fact.confidence} confidence
|
|
488
|
+
</span>
|
|
489
|
+
</Tooltip>
|
|
457
490
|
) : null}
|
|
458
491
|
{fact.message ? <span className="min-w-0 break-words text-xs leading-relaxed text-theme-text-tertiary">{fact.message}</span> : null}
|
|
459
492
|
</div>
|
|
@@ -496,11 +529,8 @@ function DiagnosticContext({
|
|
|
496
529
|
// freshness, the two facts the compact "2h" hides.
|
|
497
530
|
function ageTitle(issue: Issue): string {
|
|
498
531
|
const parts: string[] = [];
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
} else if (issue.issue_timing === 'started_after_resource_was_healthy') {
|
|
502
|
-
parts.push('Resource was previously healthy');
|
|
503
|
-
}
|
|
532
|
+
const timing = issueTiming(issue);
|
|
533
|
+
if (timing) parts.push(timing.tooltip);
|
|
504
534
|
if (issue.first_seen) parts.push(`First seen ${new Date(issue.first_seen).toLocaleString()}`);
|
|
505
535
|
if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
|
|
506
536
|
return parts.join('\n');
|
|
@@ -508,11 +538,13 @@ function ageTitle(issue: Issue): string {
|
|
|
508
538
|
|
|
509
539
|
function AffectedResources({
|
|
510
540
|
issue,
|
|
541
|
+
hideSubject,
|
|
511
542
|
resourceHref,
|
|
512
543
|
onResourceClick,
|
|
513
544
|
ResourceLinkIcon,
|
|
514
545
|
}: {
|
|
515
546
|
issue: Issue;
|
|
547
|
+
hideSubject?: boolean;
|
|
516
548
|
resourceHref?: (ref: IssueResourceRef) => string;
|
|
517
549
|
onResourceClick?: (ref: IssueResourceRef) => void;
|
|
518
550
|
ResourceLinkIcon: ComponentType<{ className?: string }>;
|
|
@@ -521,14 +553,19 @@ function AffectedResources({
|
|
|
521
553
|
// count is the backend fan-out size (members, subject excluded — see
|
|
522
554
|
// grouping.go); fall back to the inline member count, not +1.
|
|
523
555
|
const total = issue.count ?? members.length;
|
|
556
|
+
// With the subject suppressed and no fanned-out members, this section has
|
|
557
|
+
// nothing to show — return null so the divider row above it doesn't render empty.
|
|
558
|
+
if (hideSubject && members.length === 0) return null;
|
|
524
559
|
return (
|
|
525
560
|
<section className="flex flex-col gap-3">
|
|
526
561
|
{/* The subject (the grouped thing — e.g. the Deployment) is always the
|
|
527
562
|
first deep-link; members (the folded pods) follow. ResourceLine emits
|
|
528
563
|
an <li>, so it needs a list parent of its own. */}
|
|
529
|
-
|
|
530
|
-
<
|
|
531
|
-
|
|
564
|
+
{!hideSubject && (
|
|
565
|
+
<ul className="flex flex-col gap-px">
|
|
566
|
+
<ResourceLine label="Subject" compact refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
|
|
567
|
+
</ul>
|
|
568
|
+
)}
|
|
532
569
|
{members.length > 0 && (
|
|
533
570
|
<CardSection icon={Layers} label="Affected resources" labelExtra={`· ${total}`}>
|
|
534
571
|
<ul className="flex flex-col gap-px">
|
|
@@ -589,7 +626,9 @@ function ResourceLine({
|
|
|
589
626
|
{r.name}
|
|
590
627
|
</span>
|
|
591
628
|
{count && count > 1 ? (
|
|
592
|
-
<
|
|
629
|
+
<Tooltip content={`${count} affected resources grouped under this issue`} delay={200}>
|
|
630
|
+
<span className="shrink-0 text-[10px] text-theme-text-tertiary tabular-nums">{count} affected</span>
|
|
631
|
+
</Tooltip>
|
|
593
632
|
) : null}
|
|
594
633
|
{linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
|
|
595
634
|
</>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { renderToString } from 'react-dom/server'
|
|
3
|
-
import { ResourceIssuesSection } from './ResourceIssuesSection'
|
|
3
|
+
import { ResourceIssuesSection, sameResource } from './ResourceIssuesSection'
|
|
4
|
+
import { IssueRow } from './IssuesView'
|
|
4
5
|
import type { Issue } from './types'
|
|
5
6
|
|
|
6
7
|
const issue: Issue = {
|
|
@@ -28,12 +29,44 @@ const issue: Issue = {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
describe('ResourceIssuesSection', () => {
|
|
31
|
-
it('
|
|
32
|
+
it('uses the Issues row treatment and defaults collapsed', () => {
|
|
32
33
|
const html = renderToString(<ResourceIssuesSection issues={[issue]} />)
|
|
33
34
|
|
|
34
|
-
expect(html).toContain('
|
|
35
|
-
expect(html).toContain('
|
|
36
|
-
expect(html).toContain('
|
|
37
|
-
expect(html).not.toContain('
|
|
35
|
+
expect(html).toContain('Operational issues')
|
|
36
|
+
expect(html).toContain('StorageClassMissing')
|
|
37
|
+
expect(html).not.toContain('What's wrong')
|
|
38
|
+
expect(html).not.toContain('Context')
|
|
39
|
+
expect(html).not.toContain('Blocked pods')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('shows the Subject deep-link by default', () => {
|
|
43
|
+
const html = renderToString(<IssueRow issue={issue} open onToggle={() => {}} onResourceClick={() => {}} />)
|
|
44
|
+
expect(html).toContain('Subject')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('suppresses the Subject deep-link when the issue subject is the embedding resource', () => {
|
|
48
|
+
// apiKind (plural) must still resolve to the same identity as the wire kind.
|
|
49
|
+
const html = renderToString(<IssueRow issue={issue} open onToggle={() => {}} onResourceClick={() => {}} hideSubject />)
|
|
50
|
+
expect(html).not.toContain('Subject')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('sameResource', () => {
|
|
55
|
+
const subject = { kind: 'PersistentVolumeClaim', namespace: 'demo', name: 'data' }
|
|
56
|
+
|
|
57
|
+
it('matches across singular kind vs plural API name', () => {
|
|
58
|
+
expect(sameResource(subject, { kind: 'persistentvolumeclaims', namespace: 'demo', name: 'data' })).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('treats a missing namespace as empty', () => {
|
|
62
|
+
expect(sameResource({ kind: 'Node', name: 'n1' }, { kind: 'nodes', namespace: '', name: 'n1' })).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('does not match a different name', () => {
|
|
66
|
+
expect(sameResource(subject, { kind: 'persistentvolumeclaims', namespace: 'demo', name: 'other' })).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('does not match a different kind', () => {
|
|
70
|
+
expect(sameResource(subject, { kind: 'configmaps', namespace: 'demo', name: 'data' })).toBe(false)
|
|
38
71
|
})
|
|
39
72
|
})
|