@skyhook-io/k8s-ui 1.17.1 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/package.json +4 -4
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +33 -4
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
- package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
- package/src/components/gitops/insights/insights-helpers.ts +7 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
- package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
- package/src/components/gitops/tree/tree-helpers.ts +5 -1
- package/src/components/issues/IssuesView.tsx +18 -10
- package/src/components/issues/ResourceIssuesSection.tsx +5 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +26 -0
- package/src/components/issues/severity.ts +9 -2
- package/src/components/issues/types.ts +32 -4
- package/src/components/logs/LogCore.theme.test.tsx +126 -1
- package/src/components/logs/LogCore.tsx +159 -40
- package/src/components/logs/LogsViewer.test.tsx +183 -60
- package/src/components/logs/LogsViewer.tsx +49 -12
- package/src/components/logs/StructuredLogLine.tsx +1 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +198 -93
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +72 -20
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +15 -2
- package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +24 -4
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +12 -6
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +6 -4
- package/src/components/ui/Toast.tsx +48 -30
- package/src/components/ui/drawer-components.test.tsx +253 -0
- package/src/components/ui/drawer-components.tsx +315 -11
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/ui/index.ts +1 -1
- package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +68 -14
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +91 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops-insights.ts +1 -0
- package/src/types/gitops-tree.ts +2 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +22 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +56 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
healthStatusRank,
|
|
24
24
|
isArgoResourceSyncEligible,
|
|
25
25
|
messageToPhase,
|
|
26
|
-
|
|
26
|
+
missingResourceMessage,
|
|
27
27
|
normalizeSyncStatus,
|
|
28
28
|
resourceStatusCounts,
|
|
29
29
|
syncStatusRank,
|
|
@@ -237,7 +237,8 @@ function buildHealthSummary(allChanges: GitOpsChange[]): { text: string; tone: s
|
|
|
237
237
|
// Count HEALTH, not sync — a Synced resource can still be Degraded, so check
|
|
238
238
|
// degraded/missing FIRST and never let a Synced sync-state mask it. A Synced
|
|
239
239
|
// resource with no assessed health (ConfigMaps etc.) still reads as fine.
|
|
240
|
-
if (c.
|
|
240
|
+
if (c.missing) missing++
|
|
241
|
+
else if (c.health === 'Degraded' || c.category === 'Degraded') degraded++
|
|
241
242
|
else if (c.health === 'Missing' || c.category === 'Missing') missing++
|
|
242
243
|
else if (c.health === 'Healthy' || c.category === 'Synced') healthy++
|
|
243
244
|
else if (c.category === 'OutOfSync') outOfSync++
|
|
@@ -1096,9 +1097,11 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
|
|
|
1096
1097
|
const waveSet = !!planItem?.waveSet
|
|
1097
1098
|
const rowKey = refKey(change.ref)
|
|
1098
1099
|
const focused = focusKey === rowKey
|
|
1099
|
-
const explanation =
|
|
1100
|
-
?
|
|
1101
|
-
:
|
|
1100
|
+
const explanation = change.missing
|
|
1101
|
+
? missingResourceMessage(change.sync)
|
|
1102
|
+
: !change.syncError && !change.message
|
|
1103
|
+
? explainChangeStatus(change.sync, change.health, insight.summary)
|
|
1104
|
+
: ''
|
|
1102
1105
|
const hasInlineDetail = !!(
|
|
1103
1106
|
(change.drift && change.drift.entries.length > 0) ||
|
|
1104
1107
|
(change.recentEvents && change.recentEvents.length > 0)
|
|
@@ -1245,7 +1248,8 @@ function buildTreeExtras(nodes: GitOpsTreeNode[], declared: GitOpsChange[]): Git
|
|
|
1245
1248
|
// had a controller verdict to be distinguished from.
|
|
1246
1249
|
message: n.healthMessage,
|
|
1247
1250
|
hasDesired: false,
|
|
1248
|
-
hasLive:
|
|
1251
|
+
hasLive: !n.missing,
|
|
1252
|
+
missing: n.missing,
|
|
1249
1253
|
partial: true,
|
|
1250
1254
|
partialNote: 'Generated resource — not directly tracked by the GitOps controller (no drift / event diagnostics).',
|
|
1251
1255
|
})
|
|
@@ -1365,8 +1369,10 @@ function ChangeRow({
|
|
|
1365
1369
|
// differently: hide the Open pill, suppress the drawer-open click path,
|
|
1366
1370
|
// and offer "View in Git →" instead so the operator can read the
|
|
1367
1371
|
// declared source instead of a non-existent live object.
|
|
1368
|
-
const isAbsent = change.health === 'Missing' && !change.hasLive
|
|
1369
|
-
const radarNote =
|
|
1372
|
+
const isAbsent = change.missing || (change.health === 'Missing' && !change.hasLive)
|
|
1373
|
+
const radarNote = change.missing
|
|
1374
|
+
? 'Radar confirmed that this resource is not present in the cluster.'
|
|
1375
|
+
: radarHealthNote({ health: change.health, healthSource: change.healthSource, healthMessage: change.message })
|
|
1370
1376
|
const handleRowClick = () => {
|
|
1371
1377
|
if (expandable) {
|
|
1372
1378
|
setExpanded((v) => !v)
|
|
@@ -1463,7 +1469,7 @@ function ChangeRow({
|
|
|
1463
1469
|
</Tooltip>
|
|
1464
1470
|
)}
|
|
1465
1471
|
{change.message && !change.syncError && <div className="ml-[18px] mt-1 line-clamp-2 text-xs text-theme-text-secondary">{change.message}</div>}
|
|
1466
|
-
{!change.syncError && !change.message && explanation && (
|
|
1472
|
+
{!change.syncError && (change.missing || !change.message) && explanation && (
|
|
1467
1473
|
<div className="ml-[18px] mt-1 text-xs text-theme-text-tertiary">{explanation}</div>
|
|
1468
1474
|
)}
|
|
1469
1475
|
</button>
|
|
@@ -1477,7 +1483,7 @@ function ChangeRow({
|
|
|
1477
1483
|
)}
|
|
1478
1484
|
</div>
|
|
1479
1485
|
<div className="flex flex-col items-start gap-1 self-start">
|
|
1480
|
-
<HealthStatusBadge health={
|
|
1486
|
+
<HealthStatusBadge health={changeHealth(change)} />
|
|
1481
1487
|
{radarNote && (
|
|
1482
1488
|
<Tooltip content={radarNote} delay={200} wrapperClassName="inline-flex">
|
|
1483
1489
|
<span className="cursor-help select-none rounded border border-theme-border bg-theme-elevated/70 px-1.5 py-0.5 text-[10px] leading-3 text-theme-text-tertiary">
|
|
@@ -3,6 +3,7 @@ import { SEVERITY_DOT } from '../../../utils/badge-colors'
|
|
|
3
3
|
import type { GitOpsChange } from '../../../types'
|
|
4
4
|
import {
|
|
5
5
|
changeMatchesFacets,
|
|
6
|
+
changeHealth,
|
|
6
7
|
changeMatchesSearch,
|
|
7
8
|
compactSource,
|
|
8
9
|
entryTone,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
healthStatusRank,
|
|
11
12
|
isArgoResourceSyncEligible,
|
|
12
13
|
messageToPhase,
|
|
14
|
+
missingResourceMessage,
|
|
13
15
|
phaseToTone,
|
|
14
16
|
resourceStatusCounts,
|
|
15
17
|
syncStatusRank,
|
|
@@ -19,6 +21,22 @@ function change(partial: Partial<GitOpsChange> & { ref: GitOpsChange['ref'] }):
|
|
|
19
21
|
return { category: 'Unknown', hasDesired: true, hasLive: true, partial: false, ...partial }
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
describe('confirmed missing resources', () => {
|
|
25
|
+
it('counts, filters and enables sync independently of stale health and sync', () => {
|
|
26
|
+
const item = change({ ref: { kind: 'Secret', name: 'values' }, missing: true, hasLive: false, health: 'Healthy', sync: 'Synced' })
|
|
27
|
+
expect(changeHealth(item)).toBe('Missing')
|
|
28
|
+
expect(resourceStatusCounts([item]).missing).toBe(1)
|
|
29
|
+
expect(changeMatchesFacets(item, new Set(['missing']))).toBe(true)
|
|
30
|
+
expect(isArgoResourceSyncEligible(item, true)).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('asks for a refresh when Argo still reports Synced', () => {
|
|
34
|
+
expect(missingResourceMessage('Synced')).toContain('Refresh')
|
|
35
|
+
expect(missingResourceMessage('Synced')).not.toContain('Sync the application')
|
|
36
|
+
expect(missingResourceMessage('OutOfSync')).toContain('Sync the application')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
22
40
|
describe('gitopsToSeverity', () => {
|
|
23
41
|
it.each([
|
|
24
42
|
['critical', 'error'],
|
|
@@ -43,9 +43,16 @@ export function changeSync(change: GitOpsChange): SyncStatus {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
|
|
46
|
+
if (change.missing) return 'Missing'
|
|
46
47
|
return normalizeHealthStatus(change.health)
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
export function missingResourceMessage(sync?: string): string {
|
|
51
|
+
return sync === 'Synced'
|
|
52
|
+
? 'Argo reports this resource as synced, but it is not present in the cluster. Refresh the application to check its status.'
|
|
53
|
+
: 'Not present in the cluster. Sync the application to create it.'
|
|
54
|
+
}
|
|
55
|
+
|
|
49
56
|
export function isArgoResourceSyncEligible(change: GitOpsChange, declared: boolean, hook?: string): boolean {
|
|
50
57
|
if (!declared || hook || change.hookPhase) return false
|
|
51
58
|
return changeSync(change) === 'OutOfSync' || changeHealth(change) === 'Missing' || change.category === 'Missing'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import { getSubtitle, nodeCause } from './GitOpsTreeGraph'
|
|
4
|
-
import type { GitOpsTreeNode } from '../../../types'
|
|
3
|
+
import { buildCauseByRef, getSubtitle, nodeCause } from './GitOpsTreeGraph'
|
|
4
|
+
import type { GitOpsIssue, GitOpsTreeNode } from '../../../types'
|
|
5
5
|
|
|
6
6
|
function node(extras: Partial<GitOpsTreeNode> = {}): GitOpsTreeNode {
|
|
7
7
|
return {
|
|
@@ -14,6 +14,9 @@ function node(extras: Partial<GitOpsTreeNode> = {}): GitOpsTreeNode {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
describe('getSubtitle', () => {
|
|
17
|
+
it('shows confirmed absence even when controller health is unknown or stale', () => {
|
|
18
|
+
expect(getSubtitle(node({ tool: 'argocd', missing: true, sync: 'OutOfSync', health: 'Healthy' }))).toBe('OutOfSync • Not found in cluster')
|
|
19
|
+
})
|
|
17
20
|
it('shows what a healthy resource exposes, not that it is healthy', () => {
|
|
18
21
|
// The status dot and the left border stripe already paint a healthy node
|
|
19
22
|
// green, so the word adds nothing and the ports are the only thing here the
|
|
@@ -95,23 +98,84 @@ describe('nodeCause', () => {
|
|
|
95
98
|
// Shapes as the tree API sends them: the Go side maps Argo's Degraded and
|
|
96
99
|
// Missing to topologyStatus 'unhealthy', Progressing and Suspended to
|
|
97
100
|
// 'degraded' (pkg/gitops/tree healthToTopology).
|
|
98
|
-
const
|
|
101
|
+
const FAILURE = { text: 'Back-off restarting failed container main', lead: false }
|
|
102
|
+
const LEAD = { text: 'Elasticsearch cluster health degraded', lead: true }
|
|
99
103
|
|
|
100
104
|
it('shows the cause on a node the controller calls Degraded', () => {
|
|
101
|
-
expect(nodeCause(node({ health: 'Degraded', healthSource: 'controller', topologyStatus: 'unhealthy' }),
|
|
105
|
+
expect(nodeCause(node({ health: 'Degraded', healthSource: 'controller', topologyStatus: 'unhealthy' }), FAILURE)).toBe(FAILURE.text)
|
|
102
106
|
})
|
|
103
107
|
|
|
104
|
-
it('hides
|
|
108
|
+
it('hides a failure verdict on a Progressing node, even when an issue matched it', () => {
|
|
105
109
|
// A rollout in flight is not broken; a cause tooltip on it says it is.
|
|
106
|
-
expect(nodeCause(node({ health: 'Progressing', healthSource: 'controller', topologyStatus: 'degraded' }),
|
|
107
|
-
expect(nodeCause(node({ health: 'Progressing', healthSource: 'radar', topologyStatus: 'degraded' }),
|
|
110
|
+
expect(nodeCause(node({ health: 'Progressing', healthSource: 'controller', topologyStatus: 'degraded' }), FAILURE)).toBeUndefined()
|
|
111
|
+
expect(nodeCause(node({ health: 'Progressing', healthSource: 'radar', topologyStatus: 'degraded' }), FAILURE)).toBeUndefined()
|
|
108
112
|
})
|
|
109
113
|
|
|
110
114
|
it('hides it on a Suspended node, even when an issue matched it', () => {
|
|
111
|
-
expect(nodeCause(node({ health: 'Suspended', healthSource: 'controller', topologyStatus: 'degraded' }),
|
|
115
|
+
expect(nodeCause(node({ health: 'Suspended', healthSource: 'controller', topologyStatus: 'degraded' }), FAILURE)).toBeUndefined()
|
|
116
|
+
expect(nodeCause(node({ health: 'Suspended', healthSource: 'controller', topologyStatus: 'degraded' }), LEAD)).toBeUndefined()
|
|
112
117
|
})
|
|
113
118
|
|
|
114
119
|
it('never shows it on a healthy node', () => {
|
|
115
|
-
expect(nodeCause(node({ health: 'Healthy', healthSource: 'controller', topologyStatus: 'healthy' }),
|
|
120
|
+
expect(nodeCause(node({ health: 'Healthy', healthSource: 'controller', topologyStatus: 'healthy' }), FAILURE)).toBeUndefined()
|
|
121
|
+
expect(nodeCause(node({ health: 'Healthy', healthSource: 'radar', topologyStatus: 'healthy' }), LEAD)).toBeUndefined()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('shows a lead on a node that is not failing, framed as possible', () => {
|
|
125
|
+
// Argo CD 3 does not persist per-resource health, so an Argo-Degraded app
|
|
126
|
+
// can have every node read Progressing or unknown by Radar. The events
|
|
127
|
+
// lead is then the only pointer, and it lands on one of those nodes.
|
|
128
|
+
expect(nodeCause(node({ health: 'Progressing', healthSource: 'radar', topologyStatus: 'degraded' }), LEAD))
|
|
129
|
+
.toBe('Possible cause: Elasticsearch cluster health degraded')
|
|
130
|
+
expect(nodeCause(node({ ref: { group: 'elasticsearch.k8s.elastic.co', kind: 'Elasticsearch', name: 'logs', namespace: 'search' }, topologyStatus: 'unknown' }), LEAD))
|
|
131
|
+
.toBe('Possible cause: Elasticsearch cluster health degraded')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('shows a Flux source fetch error on the source while Flux retries it', () => {
|
|
135
|
+
const source = node({
|
|
136
|
+
ref: { group: 'source.toolkit.fluxcd.io', kind: 'GitRepository', name: 'podinfo', namespace: 'flux-system' },
|
|
137
|
+
health: 'Progressing',
|
|
138
|
+
topologyStatus: 'degraded',
|
|
139
|
+
})
|
|
140
|
+
// Keyed like the tree's own nodes, so the issue's ref finds this node.
|
|
141
|
+
const cause = buildCauseByRef([{
|
|
142
|
+
severity: 'warning',
|
|
143
|
+
scope: 'tree',
|
|
144
|
+
reason: 'SourceNotReady',
|
|
145
|
+
message: 'GitRepository flux-system/podinfo can\'t fetch updates.',
|
|
146
|
+
cause: 'authentication required',
|
|
147
|
+
refs: [{ group: 'source.toolkit.fluxcd.io', kind: 'GitRepository', namespace: 'flux-system', name: 'podinfo' }],
|
|
148
|
+
}]).get('source.toolkit.fluxcd.io/GitRepository/flux-system/podinfo')
|
|
149
|
+
expect(nodeCause(source, cause)).toBe('Possible cause: authentication required')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('shows a lead as is on a failing node', () => {
|
|
153
|
+
expect(nodeCause(node({ health: 'Degraded', topologyStatus: 'unhealthy' }), LEAD)).toBe(LEAD.text)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('buildCauseByRef', () => {
|
|
158
|
+
const ref = { group: 'apps', kind: 'Deployment', namespace: 'demo', name: 'web' }
|
|
159
|
+
function issue(extras: Partial<GitOpsIssue>): GitOpsIssue {
|
|
160
|
+
return { severity: 'critical', scope: 'resource', reason: 'Degraded', message: 'Deployment web is Degraded', cause: 'crashloop', refs: [ref], ...extras }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
it('marks the Warning-event lead and the Flux blockers as leads, and nothing else', () => {
|
|
164
|
+
const lead = (reason: string) => [...buildCauseByRef([issue({ reason })]).values()][0]?.lead
|
|
165
|
+
expect(lead('PossibleCause')).toBe(true)
|
|
166
|
+
expect(lead('SourceNotReady')).toBe(true)
|
|
167
|
+
expect(lead('ArtifactFailed')).toBe(true)
|
|
168
|
+
expect(lead('DependencyNotReady')).toBe(true)
|
|
169
|
+
expect(lead('Degraded')).toBe(false)
|
|
170
|
+
expect(lead('Failed')).toBe(false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('keeps the first, most severe cause for a node', () => {
|
|
174
|
+
const map = buildCauseByRef([issue({ cause: 'crashloop' }), issue({ severity: 'warning', reason: 'PossibleCause', cause: 'probe failed' })])
|
|
175
|
+
expect([...map.values()]).toEqual([{ text: 'crashloop', lead: false }])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('skips issues without a cause or a ref', () => {
|
|
179
|
+
expect(buildCauseByRef([issue({ cause: undefined }), issue({ refs: [] })]).size).toBe(0)
|
|
116
180
|
})
|
|
117
181
|
})
|
|
@@ -28,7 +28,7 @@ import { radarHealthNote } from '../health-provenance'
|
|
|
28
28
|
import { servicePortsTooltip, type ServicePortEntry } from '../../topology/K8sResourceNode'
|
|
29
29
|
import { getTopologyIcon } from '../../../utils/resource-icons'
|
|
30
30
|
import { Tooltip } from '../../ui/Tooltip'
|
|
31
|
-
import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
|
|
31
|
+
import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, treeNodeHealth, type GitOpsTreeFilters } from './tree-helpers'
|
|
32
32
|
import { rankTree, renderedEdge, shortcutEdges, upstreamClosure, upstreamEdges } from './layout'
|
|
33
33
|
|
|
34
34
|
export type GitOpsTreePreset = 'full' | 'compact' | 'workloads' | 'app'
|
|
@@ -124,19 +124,7 @@ function GitOpsTreeGraphInner({
|
|
|
124
124
|
useEffect(() => {
|
|
125
125
|
if (preset !== 'compact') setExpandedGroups(new Set())
|
|
126
126
|
}, [preset])
|
|
127
|
-
const causeByRef = useMemo(() =>
|
|
128
|
-
const map = new Map<string, string>()
|
|
129
|
-
for (const issue of issues ?? []) {
|
|
130
|
-
const cause = issue.cause
|
|
131
|
-
const ref = issue.refs?.[0]
|
|
132
|
-
if (!cause || !ref) continue
|
|
133
|
-
const key = refKey(ref)
|
|
134
|
-
// First match wins — issues arrive ranked by severity, so a node
|
|
135
|
-
// that's the subject of multiple issues keeps its most severe cause.
|
|
136
|
-
if (!map.has(key)) map.set(key, cause)
|
|
137
|
-
}
|
|
138
|
-
return map
|
|
139
|
-
}, [issues])
|
|
127
|
+
const causeByRef = useMemo(() => buildCauseByRef(issues), [issues])
|
|
140
128
|
const { nodes, edges } = useMemo(() => buildFlowGraph(tree, preset, query, filters, expandedGroups, causeByRef), [tree, preset, query, filters, expandedGroups, causeByRef])
|
|
141
129
|
|
|
142
130
|
useEffect(() => {
|
|
@@ -330,7 +318,7 @@ function getEdgeColor(type: string): string {
|
|
|
330
318
|
}
|
|
331
319
|
}
|
|
332
320
|
|
|
333
|
-
function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>, causeByRef?: Map<string,
|
|
321
|
+
function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>, causeByRef?: Map<string, NodeCause>): { nodes: Node[]; edges: Edge[] } {
|
|
334
322
|
if (!tree) return { nodes: [], edges: [] }
|
|
335
323
|
const visibleTree = applyGraphFilters(applyPreset(tree, preset, expandedGroups), filters)
|
|
336
324
|
const byID = new Map(visibleTree.nodes.map(node => [node.id, node]))
|
|
@@ -550,17 +538,15 @@ function positionRanks(ranks: Map<number, string[]>, nodes: Map<string, GitOpsTr
|
|
|
550
538
|
return positioned
|
|
551
539
|
}
|
|
552
540
|
|
|
553
|
-
const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean; cause?:
|
|
541
|
+
const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean; cause?: NodeCause }>>) {
|
|
554
542
|
const node = data.node
|
|
555
543
|
const kind = normalizeDisplayKind(node)
|
|
556
|
-
const status = normalizeHealth(node.topologyStatus)
|
|
544
|
+
const status = node.missing ? 'unhealthy' : normalizeHealth(node.topologyStatus)
|
|
557
545
|
const terminating = isNodeTerminating(node)
|
|
558
546
|
const gitopsTool = gitopsToolForNode(node)
|
|
559
547
|
const chips = buildChips(node)
|
|
560
548
|
const dim = getNodeDimensions(node)
|
|
561
549
|
const KindIcon = getTopologyIcon(kind)
|
|
562
|
-
// Only unhealthy nodes carry a cause worth surfacing — a healthy node
|
|
563
|
-
// matched by a stale/unrelated issue ref shouldn't show a tooltip.
|
|
564
550
|
const cause = nodeCause(node, data.cause)
|
|
565
551
|
// Only declared resources can carry the controller's verdict, so only
|
|
566
552
|
// there does "this one is Radar's" mean anything. Generated children
|
|
@@ -741,17 +727,47 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
|
|
|
741
727
|
const relationship = stringData(data.relationship)
|
|
742
728
|
if (relationship) chips.push({ value: relationship })
|
|
743
729
|
if (node.sync === 'OutOfSync') chips.push({ value: 'OutOfSync', tone: 'warning' })
|
|
744
|
-
|
|
730
|
+
const health = treeNodeHealth(node)
|
|
731
|
+
if (health === 'Degraded' || health === 'Missing') chips.push({ value: health, tone: 'danger' })
|
|
745
732
|
return chips
|
|
746
733
|
}
|
|
747
734
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
735
|
+
export interface NodeCause {
|
|
736
|
+
text: string
|
|
737
|
+
// An issue that names a resource as a lead without saying it failed: Argo's
|
|
738
|
+
// Warning-event lead (Go: degradedResourceFromEvents) and the Flux
|
|
739
|
+
// source/dependency blockers (Go: attachFluxBlocker, fluxSourceWarnings).
|
|
740
|
+
lead: boolean
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const LEAD_REASONS = new Set(['PossibleCause', 'SourceNotReady', 'ArtifactFailed', 'DependencyNotReady'])
|
|
744
|
+
|
|
745
|
+
export function buildCauseByRef(issues?: GitOpsIssue[]): Map<string, NodeCause> {
|
|
746
|
+
const map = new Map<string, NodeCause>()
|
|
747
|
+
for (const issue of issues ?? []) {
|
|
748
|
+
const cause = issue.cause
|
|
749
|
+
const ref = issue.refs?.[0]
|
|
750
|
+
if (!cause || !ref) continue
|
|
751
|
+
const key = refKey(ref)
|
|
752
|
+
// First match wins — issues arrive ranked by severity, so a node
|
|
753
|
+
// that's the subject of multiple issues keeps its most severe cause.
|
|
754
|
+
if (!map.has(key)) map.set(key, { text: cause, lead: LEAD_REASONS.has(issue.reason) })
|
|
755
|
+
}
|
|
756
|
+
return map
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// A failing node (topology 'unhealthy') shows any cause as is. A failure
|
|
760
|
+
// verdict on a Progressing node would read as "this is broken" on a rollout
|
|
761
|
+
// in flight, so elsewhere only a lead shows, framed as possible. That is where
|
|
762
|
+
// leads land: the Warning-event lead is only emitted when no resource reads
|
|
763
|
+
// as failing, and a Flux source mid-retry reads Progressing while its fetch
|
|
764
|
+
// error is still the answer. Healthy and Suspended nodes show nothing.
|
|
765
|
+
export function nodeCause(node: GitOpsTreeNode, cause?: NodeCause): string | undefined {
|
|
766
|
+
if (!cause) return undefined
|
|
767
|
+
const status = normalizeHealth(node.topologyStatus)
|
|
768
|
+
if (status === 'unhealthy') return cause.text
|
|
769
|
+
if (!cause.lead || status === 'healthy' || node.health === 'Suspended') return undefined
|
|
770
|
+
return `Possible cause: ${cause.text}`
|
|
755
771
|
}
|
|
756
772
|
|
|
757
773
|
// The subtitle is the node's one line of prose, and sync, health and the
|
|
@@ -774,6 +790,7 @@ export function getSubtitle(node: GitOpsTreeNode): string {
|
|
|
774
790
|
// a state description, not an affordance.
|
|
775
791
|
return 'Click to expand'
|
|
776
792
|
}
|
|
793
|
+
if (node.missing) return `${node.sync ? `${node.sync} • ` : ''}Not found in cluster`
|
|
777
794
|
if (isNodeTerminating(node)) {
|
|
778
795
|
return 'Pending deletion'
|
|
779
796
|
}
|
|
@@ -810,7 +827,7 @@ function matchesQuery(node: GitOpsTreeNode, query: string): boolean {
|
|
|
810
827
|
node.ref.namespace,
|
|
811
828
|
node.ref.group,
|
|
812
829
|
node.sync,
|
|
813
|
-
node
|
|
830
|
+
treeNodeHealth(node),
|
|
814
831
|
].some(value => String(value ?? '').toLowerCase().includes(query))
|
|
815
832
|
}
|
|
816
833
|
|
|
@@ -6,6 +6,12 @@ import type { GitOpsResourceTree, GitOpsTreeNode } from '../../../types'
|
|
|
6
6
|
const node = (group: string): GitOpsTreeNode => ({ id: group, ref: { kind: 'Service', group, name: 'same', namespace: 'team' }, role: 'generated', tool: 'fluxcd' })
|
|
7
7
|
|
|
8
8
|
describe('GitOps kind filter contract', () => {
|
|
9
|
+
it('uses confirmed absence in health facets rather than stale health', () => {
|
|
10
|
+
const missing = { ...node(''), missing: true, health: 'Healthy' as const }
|
|
11
|
+
expect(matchesGitOpsTreeFilters(missing, { health: ['Missing'] })).toBe(true)
|
|
12
|
+
expect(matchesGitOpsTreeFilters(missing, { health: ['Healthy'] })).toBe(false)
|
|
13
|
+
expect(buildTreeFacets({ nodes: [missing] } as GitOpsResourceTree).health).toEqual([{ name: 'Missing', count: 1 }])
|
|
14
|
+
})
|
|
9
15
|
it('uses bare Kubernetes kinds across API groups', () => {
|
|
10
16
|
const nodes = [node(''), node('serving.knative.dev')]
|
|
11
17
|
expect(buildTreeFacets({ nodes } as GitOpsResourceTree).kinds).toEqual([{ name: 'Service', count: 2 }])
|
|
@@ -20,6 +20,10 @@ export function treeNodeFilterRole(node: GitOpsTreeNode): string {
|
|
|
20
20
|
return node.dependency ? 'dependency' : node.role
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export function treeNodeHealth(node: GitOpsTreeNode): string {
|
|
24
|
+
return node.missing ? 'Missing' : node.health || 'Unknown'
|
|
25
|
+
}
|
|
26
|
+
|
|
23
27
|
export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsTreeFilters): boolean {
|
|
24
28
|
if (!filters) return true
|
|
25
29
|
const kinds = gitOpsFilterSet(filters.kinds)
|
|
@@ -31,7 +35,7 @@ export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsT
|
|
|
31
35
|
if (kinds && !kinds.has(node.ref.kind)) return false
|
|
32
36
|
if (namespaces && !namespaces.has(node.ref.namespace || '(cluster)')) return false
|
|
33
37
|
if (sync && !sync.has(node.sync || 'Unknown')) return false
|
|
34
|
-
if (health && !health.has(node
|
|
38
|
+
if (health && !health.has(treeNodeHealth(node))) return false
|
|
35
39
|
if (roles && !roles.has(treeNodeFilterRole(node))) return false
|
|
36
40
|
return true
|
|
37
41
|
}
|
|
@@ -288,13 +288,16 @@ export function IssueRow({
|
|
|
288
288
|
onToggle();
|
|
289
289
|
}
|
|
290
290
|
}}
|
|
291
|
-
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,var(--accent))]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
|
|
291
|
+
className={`group @container/issue flex cursor-pointer items-center gap-3 phone:flex-wrap phone:gap-y-2 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,var(--accent))]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
|
|
292
292
|
>
|
|
293
293
|
<SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
|
|
294
294
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
295
|
+
{/* Phones: the title takes its own line with the badge and reason
|
|
296
|
+
under it, and the severity, actions and chevron get a last line
|
|
297
|
+
(the right cluster below wraps under the text). */}
|
|
298
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1 phone:basis-[calc(100%-30px)]">
|
|
299
|
+
<div className="flex min-w-0 items-baseline gap-2 phone:flex-wrap phone:gap-y-1">
|
|
300
|
+
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary phone:basis-full">{issueTitle(issue)}</span>
|
|
298
301
|
<span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
|
|
299
302
|
{renderBadges?.(slotCtx)}
|
|
300
303
|
{/* The detector reason rides the title row while COLLAPSED so the
|
|
@@ -351,15 +354,17 @@ export function IssueRow({
|
|
|
351
354
|
{/* Narrow container: the meta chips drop to their own line here rather
|
|
352
355
|
than stealing width from the title on the row above. Hidden once the
|
|
353
356
|
row is wide enough to hold them inline in the right cluster. */}
|
|
354
|
-
{metaChips('flex @2xl/issue:hidden')}
|
|
357
|
+
{metaChips('flex @2xl/issue:hidden phone:hidden')}
|
|
355
358
|
</div>
|
|
356
359
|
|
|
357
360
|
{/* Right cluster — the always-visible affordances (per-row action +
|
|
358
361
|
chevron) plus, on a wide container, the inline meta chips. */}
|
|
359
|
-
<div className="flex shrink-0 items-center gap-3">
|
|
360
|
-
{metaChips('hidden @2xl/issue:flex')}
|
|
361
|
-
|
|
362
|
-
|
|
362
|
+
<div className="flex shrink-0 items-center gap-3 phone:basis-full phone:pl-[30px]">
|
|
363
|
+
{metaChips('hidden @2xl/issue:flex phone:flex')}
|
|
364
|
+
<div className="contents phone:ml-auto phone:flex phone:items-center phone:gap-3">
|
|
365
|
+
{renderActions?.(slotCtx)}
|
|
366
|
+
<CollapseChevron open={open} className="h-4 w-4" />
|
|
367
|
+
</div>
|
|
363
368
|
</div>
|
|
364
369
|
</div>
|
|
365
370
|
|
|
@@ -435,9 +440,12 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
|
|
|
435
440
|
const partialUnknown = issue.onset_coverage?.unknown ?? 0;
|
|
436
441
|
const partialOnset = Boolean(issue.first_seen && partialUnknown > 0);
|
|
437
442
|
const timing = issueTimingForDisplay(issue);
|
|
443
|
+
// A message Radar wrote itself is not raw output, so it never lands in the
|
|
444
|
+
// terminal block; its cause carries the same facts.
|
|
445
|
+
const messageIsRaw = issue.message_source !== 'radar';
|
|
438
446
|
const rawError = [
|
|
439
447
|
...new Set(
|
|
440
|
-
[issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
|
|
448
|
+
[issue.raw_message ?? (issue.cause && messageIsRaw ? issue.message : undefined), detail].filter(
|
|
441
449
|
(s): s is string => !!s && s !== shownText,
|
|
442
450
|
),
|
|
443
451
|
),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react'
|
|
1
|
+
import { useMemo, useState, type ReactNode } from 'react'
|
|
2
2
|
import { AlertTriangle } from 'lucide-react'
|
|
3
3
|
import { kindToPluralWithGroup } from '../../utils/navigation'
|
|
4
|
-
import { IssueRow } from './IssuesView'
|
|
4
|
+
import { IssueRow, type IssueRowSlotContext } from './IssuesView'
|
|
5
5
|
import { compareIssues, subjectRef, type Issue, type IssueResourceRef } from './types'
|
|
6
6
|
|
|
7
7
|
export function ResourceIssuesSection({
|
|
@@ -9,8 +9,10 @@ export function ResourceIssuesSection({
|
|
|
9
9
|
onResourceClick,
|
|
10
10
|
subjectResource,
|
|
11
11
|
compact,
|
|
12
|
+
renderDetailSection,
|
|
12
13
|
}: {
|
|
13
14
|
compact?: boolean
|
|
15
|
+
renderDetailSection?: (ctx: IssueRowSlotContext) => ReactNode
|
|
14
16
|
issues: Issue[] | undefined
|
|
15
17
|
/** When provided, related resources in a causal link become clickable. */
|
|
16
18
|
onResourceClick?: (ref: IssueResourceRef) => void
|
|
@@ -39,6 +41,7 @@ export function ResourceIssuesSection({
|
|
|
39
41
|
key={key}
|
|
40
42
|
issue={issue}
|
|
41
43
|
compact={compact}
|
|
44
|
+
renderDetailSection={renderDetailSection}
|
|
42
45
|
open={openId === key}
|
|
43
46
|
onToggle={() => setOpenId((cur) => (cur === key ? null : key))}
|
|
44
47
|
onResourceClick={onResourceClick}
|
|
@@ -52,6 +52,8 @@ export function diagnosticFactLabel(type: string): string {
|
|
|
52
52
|
return 'Dependent pods';
|
|
53
53
|
case 'admission_webhook_backend':
|
|
54
54
|
return 'Admission dependency';
|
|
55
|
+
case 'network_policy_isolates':
|
|
56
|
+
return 'Affected resources';
|
|
55
57
|
default:
|
|
56
58
|
return type.replace(/_/g, ' ');
|
|
57
59
|
}
|
|
@@ -16,7 +16,7 @@ export {
|
|
|
16
16
|
issueSortAnchor,
|
|
17
17
|
compareIssueSortAnchors,
|
|
18
18
|
} from './types';
|
|
19
|
-
export type { Issue, IssueConditionalCleanup, IssueFinalizerRemoval, 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, IssueRunningPreviousValue, IssueWithheld } from './types';
|
|
20
20
|
export {
|
|
21
21
|
ISSUE_SEVERITY_LABEL,
|
|
22
22
|
ISSUE_SEVERITY_BADGE_CLASS,
|
|
@@ -537,4 +537,30 @@ describe('IssueRow diagnosis raw messages', () => {
|
|
|
537
537
|
expect(html).toContain('app path does not exist')
|
|
538
538
|
expect(html).toContain('rpc error: code = Unknown desc = app path does not exist')
|
|
539
539
|
})
|
|
540
|
+
|
|
541
|
+
it("doesn't present Radar's own summary as a raw error", () => {
|
|
542
|
+
const base = {
|
|
543
|
+
category: 'network_policy_denied',
|
|
544
|
+
category_group: 'networking',
|
|
545
|
+
severity: 'warning' as const,
|
|
546
|
+
reason: 'NetworkPolicy denies all ingress',
|
|
547
|
+
message: 'Would block all inbound traffic to the 2 pods behind Service recommendation',
|
|
548
|
+
cause: 'NetworkPolicy deny-all leaves the 2 pods behind Service recommendation with no allowed inbound traffic.',
|
|
549
|
+
}
|
|
550
|
+
const radar = renderToString(createElement(IssueRow, {
|
|
551
|
+
issue: mk({
|
|
552
|
+
...base,
|
|
553
|
+
message_source: 'radar',
|
|
554
|
+
diagnostic_context: { role: 'candidate', facts: [{ type: 'network_policy_isolates', confidence: 'high', refs: [{ kind: 'Service', namespace: 'shop', name: 'recommendation' }] }] },
|
|
555
|
+
}),
|
|
556
|
+
open: true, onToggle: () => undefined, as: 'div',
|
|
557
|
+
}))
|
|
558
|
+
expect(radar).not.toContain('Raw error')
|
|
559
|
+
expect(radar).toContain('leaves the 2 pods behind Service recommendation')
|
|
560
|
+
expect(radar).toContain('Affected resources')
|
|
561
|
+
|
|
562
|
+
// Without the marker the message is still treated as detector output.
|
|
563
|
+
const unmarked = renderToString(createElement(IssueRow, { issue: mk(base), open: true, onToggle: () => undefined, as: 'div' }))
|
|
564
|
+
expect(unmarked).toContain('Raw error')
|
|
565
|
+
})
|
|
540
566
|
})
|
|
@@ -70,13 +70,14 @@ const CATEGORY_LABEL: Record<string, string> = {
|
|
|
70
70
|
pdb_blocks_evictions: 'Evictions blocked',
|
|
71
71
|
secret_sync_failed: 'Secret sync failed',
|
|
72
72
|
service_no_endpoints: 'No endpoints',
|
|
73
|
+
service_mixed_backends: 'Mixed Service backends',
|
|
73
74
|
ingress_backend_missing: 'Ingress backend missing',
|
|
74
75
|
ingress_class_missing: 'Ingress class missing',
|
|
75
76
|
load_balancer_pending: 'Load balancer pending',
|
|
76
77
|
gateway_not_ready: 'Gateway not ready',
|
|
77
78
|
gateway_route_invalid: 'Gateway route invalid',
|
|
78
79
|
dns_failure: 'DNS failure',
|
|
79
|
-
|
|
80
|
+
network_policy_denied: 'Traffic denied by NetworkPolicy',
|
|
80
81
|
pvc_pending: 'PVC pending',
|
|
81
82
|
pvc_lost: 'PVC lost',
|
|
82
83
|
pv_failed: 'PV failed',
|
|
@@ -87,7 +88,7 @@ const CATEGORY_LABEL: Record<string, string> = {
|
|
|
87
88
|
backup_target_unavailable: 'Backup target unavailable',
|
|
88
89
|
backup_stalled: 'Backup not progressing',
|
|
89
90
|
job_failed: 'Job failed',
|
|
90
|
-
cronjob_failed: 'CronJob
|
|
91
|
+
cronjob_failed: 'CronJob needs attention',
|
|
91
92
|
rollout_stalled: 'Rollout stalled',
|
|
92
93
|
hpa_limited_or_failed: 'Autoscaling limited',
|
|
93
94
|
rbac_forbidden: 'Permission denied',
|
|
@@ -139,6 +140,12 @@ export function categoryLabel(category: string): string {
|
|
|
139
140
|
// Reasons filed under a category whose label names a different operation:
|
|
140
141
|
// CNPG archiving and an unanswered scheduled run sit under backup_failed.
|
|
141
142
|
const REASON_TITLE: Record<string, string> = {
|
|
143
|
+
InvalidSchedule: 'Scheduling error reported',
|
|
144
|
+
UnparseableSchedule: 'Scheduling error reported',
|
|
145
|
+
UnParseableCronJobSchedule: 'Scheduling error reported',
|
|
146
|
+
UnknownTimeZone: 'Time zone error reported',
|
|
147
|
+
MissSchedule: 'Missed schedule reported',
|
|
148
|
+
TooManyMissedTimes: 'Missed schedules reported',
|
|
142
149
|
CNPGWALArchivingFailing: "WAL archiving failing",
|
|
143
150
|
CNPGLastBackupFailed: "Latest backup failed",
|
|
144
151
|
CNPGScheduleDestinationMissing: "Backup schedule has no destination",
|