@skyhook-io/k8s-ui 1.18.2 → 1.19.0
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 +1 -1
- package/src/components/charts/AreaChart.test.tsx +39 -1
- package/src/components/charts/AreaChart.tsx +32 -13
- package/src/components/charts/axis.test.ts +17 -1
- package/src/components/charts/axis.ts +13 -0
- package/src/components/charts/format.test.ts +159 -2
- package/src/components/charts/format.ts +118 -0
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
- package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
- package/src/components/gitops/insights/insights-helpers.ts +5 -3
- package/src/components/issues/IssueGuidance.test.tsx +190 -0
- package/src/components/issues/IssueGuidance.tsx +48 -0
- package/src/components/issues/IssuesView.tsx +15 -3
- package/src/components/issues/index.ts +1 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/issues/types.ts +10 -0
- package/src/types/core.ts +1 -0
- package/src/types/gitops-insights.ts +8 -0
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
3
|
import { AreaChart } from './AreaChart'
|
|
4
4
|
import { layoutAnnotations, ANNOTATION_LABEL_ROWS } from './annotations'
|
|
5
|
-
import { formatTimestamp } from './format'
|
|
5
|
+
import { alignedTimeAxisTicks, formatTimestamp } from './format'
|
|
6
6
|
import type { ChartAnnotation, TimeSeries } from './types'
|
|
7
7
|
|
|
8
8
|
const t0 = 1_788_679_000
|
|
@@ -82,6 +82,29 @@ describe('AreaChart domain and axes', () => {
|
|
|
82
82
|
expect(Math.max(...xs)).toBeCloseTo(960, 0)
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
it('labels a multi-day X axis on local midnights', () => {
|
|
86
|
+
const week = 7 * 86_400
|
|
87
|
+
const html = render({ series: [series([[0, 1], [week, 2]])], domain: { start: t0, end: t0 + week } })
|
|
88
|
+
const ticks = alignedTimeAxisTicks(t0, t0 + week, 7)!
|
|
89
|
+
expect(ticks.length).toBeGreaterThanOrEqual(2)
|
|
90
|
+
for (const { label } of ticks) expect(html).toContain(`>${label}<`)
|
|
91
|
+
expect(html).not.toContain(`>${formatTimestamp(t0)}<`)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('keeps every X label inside the viewBox on a multi-day axis', () => {
|
|
95
|
+
for (const days of [1.5, 2, 3, 7, 11, 270]) {
|
|
96
|
+
const span = days * 86_400
|
|
97
|
+
const html = render({ series: [series([[0, 1], [span, 2]])], domain: { start: t0, end: t0 + span } })
|
|
98
|
+
const xLabels = [...html.matchAll(/<text x="([^"]+)" y="296" text-anchor="middle"[^>]*>([^<]*)<\/text>/g)]
|
|
99
|
+
expect(xLabels.length).toBeGreaterThanOrEqual(2)
|
|
100
|
+
for (const [, x, label] of xLabels) {
|
|
101
|
+
const half = (label.length * 11 * 0.6) / 2
|
|
102
|
+
expect(Number(x) - half).toBeGreaterThanOrEqual(0)
|
|
103
|
+
expect(Number(x) + half).toBeLessThanOrEqual(1000)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
85
108
|
it('extends the Y axis below zero and draws a zero line for negative values', () => {
|
|
86
109
|
const html = render({ series: [series([[0, -4], [600, 2], [1200, -1]])] })
|
|
87
110
|
expect(html).toContain('data-chart-zero-line')
|
|
@@ -196,3 +219,18 @@ describe('AreaChart gaps and selection', () => {
|
|
|
196
219
|
expect(html).toContain('cursor:crosshair')
|
|
197
220
|
})
|
|
198
221
|
})
|
|
222
|
+
|
|
223
|
+
describe('AreaChart dashboard height', () => {
|
|
224
|
+
const data = [series([[0, 1], [600, 2]])]
|
|
225
|
+
|
|
226
|
+
it('renders the dashboard layout at the height asked for, and 240 by default', () => {
|
|
227
|
+
const short = render({ series: data, layout: 'dashboard', height: 140 })
|
|
228
|
+
expect(short).toContain('height:140px')
|
|
229
|
+
expect(attrs(short, 'svg', 'viewBox')[0]).toMatch(/ 140$/)
|
|
230
|
+
expect(render({ series: data, layout: 'dashboard' })).toContain('height:240px')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('ignores the height outside the dashboard layout', () => {
|
|
234
|
+
expect(render({ series: data, layout: 'auto', height: 140 })).not.toContain('height:140px')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import type * as React from 'react'
|
|
3
3
|
import { seriesColor, seriesFill, computeShortLabels, seriesDisplayLabels } from './colors'
|
|
4
|
-
import { formatMetricValue,
|
|
4
|
+
import { alignedTimeAxisTicks, datesNeedYear, formatFullTimestamp, formatMetricValue, formatTimeAxisLabels, labelWidthEm, type TimeAxisTick } from './format'
|
|
5
5
|
import { layoutAnnotations } from './annotations'
|
|
6
|
-
import { chartLayout, integerAxisTop, isCountUnit, yAxisValues } from './axis'
|
|
6
|
+
import { chartLayout, integerAxisTop, isCountUnit, xLabelMaxWidthEm, yAxisValues } from './axis'
|
|
7
7
|
import type { TimeSeries, ReferenceLine, ChartAnnotation } from './types'
|
|
8
8
|
import { nearestSample } from './nearestSample'
|
|
9
9
|
|
|
@@ -19,7 +19,7 @@ export interface ChartTimeRange {
|
|
|
19
19
|
end: number
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, stepSeconds, layout = 'full', shadedRanges, selection, onSelectRange }: {
|
|
22
|
+
export function AreaChart({ series, color, fillColor, unit, referenceLines, annotations, domain, seriesLabels, stepSeconds, layout = 'full', height: dashboardHeight = 240, shadedRanges, selection, onSelectRange }: {
|
|
23
23
|
series: TimeSeries[]
|
|
24
24
|
color: string
|
|
25
25
|
fillColor: string
|
|
@@ -45,6 +45,8 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
45
45
|
* keeps text and plot height stable as its container resizes.
|
|
46
46
|
*/
|
|
47
47
|
layout?: 'full' | 'auto' | 'compact' | 'dashboard'
|
|
48
|
+
/** Rendered height in px of the 'dashboard' layout; ignored by the others. */
|
|
49
|
+
height?: number
|
|
48
50
|
/** Ranges drawn hatched, e.g. where no sample exists; `label` says why and shows on hover. */
|
|
49
51
|
shadedRanges?: (ChartTimeRange & { label?: string })[]
|
|
50
52
|
/** A selected range, drawn as a band. */
|
|
@@ -116,7 +118,8 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
116
118
|
// Layout constants. marginLeft sized for the widest expected Y-tick label
|
|
117
119
|
// ("422.4 MiB" etc.) — narrow grid panels squeeze the X axis so labels
|
|
118
120
|
// need extra viewBox-space to survive the down-scale.
|
|
119
|
-
const
|
|
121
|
+
const measured = chartLayout(compact, layout === 'dashboard', renderedWidth)
|
|
122
|
+
const base = layout === 'dashboard' ? { ...measured, height: dashboardHeight } : measured
|
|
120
123
|
const { width, height, marginRight, marginTop, marginBottom, fontSize, yIntervals, xIntervals } = base
|
|
121
124
|
const yValues = chartData ? yAxisValues(chartData.yMin, chartData.yMax, yIntervals, countAxis) : []
|
|
122
125
|
const yLabels = yValues.map(val => formatMetricValue(val, unit))
|
|
@@ -149,14 +152,30 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
149
152
|
const xTicks = useMemo(() => {
|
|
150
153
|
if (!chartData) return []
|
|
151
154
|
const { minTs, maxTs } = chartData
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
return { ts, x: toX(ts),
|
|
155
|
+
// A window longer than a day gets ticks on local midnights (and clock
|
|
156
|
+
// hours between) when at least two fit, positioned where they fall; the
|
|
157
|
+
// centred labels are nudged inside the viewBox so an end one isn't clipped.
|
|
158
|
+
const place = (ticks: TimeAxisTick[]) => ticks.map(({ ts, label }) => {
|
|
159
|
+
const halfWidth = (labelWidthEm(label) * fontSize) / 2
|
|
160
|
+
return { ts, x: Math.min(Math.max(toX(ts), halfWidth), width - halfWidth), halfWidth, label, anchor: 'middle' as const }
|
|
158
161
|
})
|
|
159
|
-
|
|
162
|
+
const separated = (ticks: TimeAxisTick[]) => place(ticks).every((tick, i, all) =>
|
|
163
|
+
i === 0 || tick.x - tick.halfWidth - (all[i - 1].x + all[i - 1].halfWidth) >= fontSize)
|
|
164
|
+
const aligned = xIntervals >= 2 ? alignedTimeAxisTicks(minTs, maxTs, xIntervals + 1, separated) : null
|
|
165
|
+
if (aligned) return place(aligned)
|
|
166
|
+
// In the compact layout the two labels sit at the plot edges and would
|
|
167
|
+
// otherwise spill past the viewBox.
|
|
168
|
+
const edgeAnchored = compact || layout === 'dashboard'
|
|
169
|
+
const timestamps = Array.from({ length: xIntervals + 1 }, (_, i) => minTs + ((maxTs - minTs) / xIntervals) * i)
|
|
170
|
+
const labels = formatTimeAxisLabels(timestamps, xLabelMaxWidthEm(plotWidth, xIntervals, fontSize, edgeAnchored))
|
|
171
|
+
const multiDay = maxTs - minTs > 24 * 60 * 60
|
|
172
|
+
return timestamps.map((ts, i) => {
|
|
173
|
+
const anchor: 'start' | 'middle' | 'end' = !edgeAnchored ? 'middle' : i === 0 ? 'start' : i === xIntervals ? 'end' : 'middle'
|
|
174
|
+
const halfWidth = (labelWidthEm(labels[i]) * fontSize) / 2
|
|
175
|
+
const x = multiDay && !edgeAnchored ? Math.min(Math.max(toX(ts), halfWidth), width - halfWidth) : toX(ts)
|
|
176
|
+
return { ts, x, label: labels[i], anchor }
|
|
177
|
+
})
|
|
178
|
+
}, [chartData, xIntervals, compact, layout, marginLeft, plotWidth, fontSize, width])
|
|
160
179
|
|
|
161
180
|
const paths = useMemo(() => {
|
|
162
181
|
if (!chartData) return []
|
|
@@ -368,7 +387,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
368
387
|
ref={svgRef}
|
|
369
388
|
viewBox={`0 0 ${width} ${height}`}
|
|
370
389
|
className="w-full h-full"
|
|
371
|
-
style={layout === 'dashboard' ? { height:
|
|
390
|
+
style={layout === 'dashboard' ? { height: dashboardHeight } : undefined}
|
|
372
391
|
preserveAspectRatio="xMidYMid meet"
|
|
373
392
|
data-chart-layout={compact ? 'compact' : 'full'}
|
|
374
393
|
>
|
|
@@ -653,7 +672,7 @@ export function AreaChart({ series, color, fillColor, unit, referenceLines, anno
|
|
|
653
672
|
>
|
|
654
673
|
<div className="bg-theme-surface border border-theme-border rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap">
|
|
655
674
|
<div className="text-theme-text-tertiary mb-1.5 font-mono">
|
|
656
|
-
{
|
|
675
|
+
{formatFullTimestamp(hoverData.ts, datesNeedYear(chartData.maxTs - chartData.minTs))}
|
|
657
676
|
</div>
|
|
658
677
|
{hoverData.nearbyAnnotations.map((placed, i) => (
|
|
659
678
|
<div key={`annotation-${i}`} className="flex items-center gap-2 py-0.5">
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { chartLayout, integerAxisTop, yAxisValues } from './axis'
|
|
2
|
+
import { chartLayout, integerAxisTop, xLabelMaxWidthEm, yAxisValues } from './axis'
|
|
3
3
|
|
|
4
4
|
describe('integer axes', () => {
|
|
5
5
|
it('ends a count axis on a whole step that reaches the maximum', () => {
|
|
@@ -46,3 +46,19 @@ describe('integer axes', () => {
|
|
|
46
46
|
expect(chartLayout(false, false, 500)).toEqual(chartLayout(false))
|
|
47
47
|
})
|
|
48
48
|
})
|
|
49
|
+
|
|
50
|
+
describe('xLabelMaxWidthEm', () => {
|
|
51
|
+
// "Oct 21 02:48 PM" is 15 characters, 9em.
|
|
52
|
+
const dateAndTime = 9
|
|
53
|
+
const room = (layout: ReturnType<typeof chartLayout>, edgeAnchored: boolean) =>
|
|
54
|
+
xLabelMaxWidthEm(layout.width - layout.marginLeft - layout.marginRight, layout.xIntervals, layout.fontSize, edgeAnchored)
|
|
55
|
+
|
|
56
|
+
it('fits a date-and-time label on the full layout and the compact edges', () => {
|
|
57
|
+
expect(room(chartLayout(false), false)).toBeGreaterThanOrEqual(dateAndTime)
|
|
58
|
+
expect(room(chartLayout(true), true)).toBeGreaterThanOrEqual(dateAndTime)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('leaves too little room for a date and time with three ticks at 400px', () => {
|
|
62
|
+
expect(room(chartLayout(false, true, 400), true)).toBeLessThan(dateAndTime)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -26,6 +26,19 @@ export function chartLayout(compact: boolean, dashboard = false, renderedWidth =
|
|
|
26
26
|
: { width: 1000, height: 300, marginLeft: 84, marginRight: 40, marginTop: 10, marginBottom: 30, fontSize: 11, yIntervals: 4, xIntervals: 6 }
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Widest X label, in ems, that leaves a gap of about two characters between
|
|
31
|
+
* neighbours. A centred label shares the spacing with half of each neighbour;
|
|
32
|
+
* an edge-anchored end label sits wholly on its side, so with two ticks both
|
|
33
|
+
* labels share the plot and with more the end label and half its neighbour
|
|
34
|
+
* share one spacing.
|
|
35
|
+
*/
|
|
36
|
+
export function xLabelMaxWidthEm(plotWidth: number, intervals: number, fontSize: number, edgeAnchored: boolean): number {
|
|
37
|
+
const spacing = plotWidth / Math.max(1, intervals) / fontSize
|
|
38
|
+
const share = !edgeAnchored ? 1 : intervals <= 1 ? 2 : 1.5
|
|
39
|
+
return (spacing - 1.2) / share
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
export function isCountUnit(unit: string): boolean {
|
|
30
43
|
return unit === 'count' || unit === 'restarts'
|
|
31
44
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { formatMetricValue } from './format'
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
2
|
+
import { alignedTimeAxisTicks, datesNeedYear, formatFullTimestamp, formatMetricValue, formatTimeAxisLabels, formatTimestamp, labelWidthEm } from './format'
|
|
3
3
|
|
|
4
4
|
describe('formatMetricValue', () => {
|
|
5
5
|
it('scales unitless values with SI prefixes instead of a runaway k', () => {
|
|
@@ -17,3 +17,160 @@ describe('formatMetricValue', () => {
|
|
|
17
17
|
expect(formatMetricValue(-2048, 'bytes')).toBe('-2.0 KiB')
|
|
18
18
|
})
|
|
19
19
|
})
|
|
20
|
+
|
|
21
|
+
describe('time axis labels', () => {
|
|
22
|
+
// Day boundaries depend on the local zone; pin one with a DST change. The
|
|
23
|
+
// expected strings come from the same Intl calls, so any locale passes.
|
|
24
|
+
let previousTZ: string | undefined
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
previousTZ = process.env.TZ
|
|
27
|
+
process.env.TZ = 'America/New_York'
|
|
28
|
+
})
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
if (previousTZ === undefined) delete process.env.TZ
|
|
31
|
+
else process.env.TZ = previousTZ
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const at = (month: number, day: number, hour: number, minute = 0) => new Date(2026, month - 1, day, hour, minute).getTime() / 1000
|
|
35
|
+
const day = (unix: number) => new Date(unix * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })
|
|
36
|
+
const ticks = (start: number, end: number, intervals: number) =>
|
|
37
|
+
Array.from({ length: intervals + 1 }, (_, i) => start + ((end - start) / intervals) * i)
|
|
38
|
+
|
|
39
|
+
describe('formatTimeAxisLabels (evenly spaced ticks)', () => {
|
|
40
|
+
it('keeps time-only labels for a window of a day or less, even across midnight', () => {
|
|
41
|
+
const sameDay = ticks(at(10, 21, 8), at(10, 21, 14), 6)
|
|
42
|
+
expect(formatTimeAxisLabels(sameDay)).toEqual(sameDay.map(formatTimestamp))
|
|
43
|
+
const overMidnight = ticks(at(10, 21, 20), at(10, 22, 2), 6)
|
|
44
|
+
expect(formatTimeAxisLabels(overMidnight)).toEqual(overMidnight.map(formatTimestamp))
|
|
45
|
+
const fullDay = ticks(at(10, 21, 14, 48), at(10, 22, 14, 48), 6)
|
|
46
|
+
expect(formatTimeAxisLabels(fullDay, 1)).toEqual(fullDay.map(formatTimestamp))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('names the day where a new one starts and the time elsewhere', () => {
|
|
50
|
+
const twoDays = ticks(at(10, 21, 14, 48), at(10, 23, 14, 48), 6)
|
|
51
|
+
const labels = formatTimeAxisLabels(twoDays)
|
|
52
|
+
expect(labels[0]).toBe(`${day(twoDays[0])} ${formatTimestamp(twoDays[0])}`)
|
|
53
|
+
expect(labels[1]).toBe(formatTimestamp(twoDays[1]))
|
|
54
|
+
expect(labels[2]).toBe(`${day(twoDays[2])} ${formatTimestamp(twoDays[2])}`)
|
|
55
|
+
expect(labels.filter((label) => label.includes(day(twoDays[0])))).toHaveLength(1)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('keeps the time on two end ticks just over a day apart', () => {
|
|
59
|
+
const edges = [at(10, 21, 14, 48), at(10, 22, 14, 49)]
|
|
60
|
+
expect(formatTimeAxisLabels(edges)).toEqual(edges.map((ts) => `${day(ts)} ${formatTimestamp(ts)}`))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('falls back to the day at day starts when date and time would not fit', () => {
|
|
64
|
+
const twoDays = ticks(at(10, 21, 14, 48), at(10, 23, 14, 48), 4)
|
|
65
|
+
expect(formatTimeAxisLabels(twoDays, 5)).toEqual([day(twoDays[0]), day(twoDays[1]), '', day(twoDays[3]), ''])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('never repeats a bare day across a DST fall-back', () => {
|
|
69
|
+
// 24h steps from 00:30 land on 23:30 after clocks go back on Nov 1.
|
|
70
|
+
const window = ticks(at(10, 31, 0, 30), at(10, 31, 0, 30) + 3 * 86_400, 3)
|
|
71
|
+
expect(formatTimeAxisLabels(window)).toEqual([
|
|
72
|
+
`${day(window[0])} ${formatTimestamp(window[0])}`,
|
|
73
|
+
`${day(window[1])} ${formatTimestamp(window[1])}`,
|
|
74
|
+
formatTimestamp(window[2]),
|
|
75
|
+
`${day(window[3])} ${formatTimestamp(window[3])}`,
|
|
76
|
+
])
|
|
77
|
+
expect(day(window[1])).toBe(day(window[2]))
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('alignedTimeAxisTicks', () => {
|
|
82
|
+
const local = (ts: number) => {
|
|
83
|
+
const d = new Date(ts * 1000)
|
|
84
|
+
return [d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes()]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
it('leaves a window of a day or less to evenly spaced ticks', () => {
|
|
88
|
+
expect(alignedTimeAxisTicks(at(10, 21, 14, 48), at(10, 22, 14, 48), 7)).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('puts a 7-day window on consecutive local midnights, labelled with the day', () => {
|
|
92
|
+
const result = alignedTimeAxisTicks(at(10, 21, 2, 48), at(10, 28, 2, 48), 7)!
|
|
93
|
+
expect(result.map(({ ts }) => local(ts))).toEqual([22, 23, 24, 25, 26, 27, 28].map((d) => [10, d, 0, 0]))
|
|
94
|
+
expect(result.map(({ label }) => label)).toEqual(result.map(({ ts }) => day(ts)))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('steps by whole days when daily ticks would not fit, and keeps them as the window slides', () => {
|
|
98
|
+
const eleven = alignedTimeAxisTicks(at(10, 17, 1, 48), at(10, 28, 1, 48), 7)!
|
|
99
|
+
expect(eleven.length).toBeLessThanOrEqual(7)
|
|
100
|
+
expect(eleven.every(({ ts }) => local(ts)[2] === 0 && local(ts)[3] === 0)).toBe(true)
|
|
101
|
+
const slid = alignedTimeAxisTicks(at(10, 17, 7, 48), at(10, 28, 7, 48), 7)!
|
|
102
|
+
const shared = (list: { ts: number }[]) => list.map(({ ts }) => ts).filter((ts) => ts >= at(10, 17, 7, 48) && ts <= at(10, 28, 1, 48))
|
|
103
|
+
expect(shared(slid)).toEqual(shared(eleven))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('adds clock-hour ticks with time labels on a short multi-day window', () => {
|
|
107
|
+
const result = alignedTimeAxisTicks(at(10, 21, 14, 48), at(10, 23, 2, 48), 7)!
|
|
108
|
+
expect(result.map(({ ts }) => local(ts))).toEqual([
|
|
109
|
+
[10, 21, 18, 0],
|
|
110
|
+
[10, 22, 0, 0],
|
|
111
|
+
[10, 22, 6, 0],
|
|
112
|
+
[10, 22, 12, 0],
|
|
113
|
+
[10, 22, 18, 0],
|
|
114
|
+
[10, 23, 0, 0],
|
|
115
|
+
])
|
|
116
|
+
expect(result.map(({ label }) => label)).toEqual([
|
|
117
|
+
formatTimestamp(result[0].ts),
|
|
118
|
+
day(result[1].ts),
|
|
119
|
+
formatTimestamp(result[2].ts),
|
|
120
|
+
formatTimestamp(result[3].ts),
|
|
121
|
+
formatTimestamp(result[4].ts),
|
|
122
|
+
day(result[5].ts),
|
|
123
|
+
])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('keeps local midnights across a DST change', () => {
|
|
127
|
+
const result = alignedTimeAxisTicks(at(10, 30, 12), at(11, 3, 12), 7)!
|
|
128
|
+
expect(result.map(({ ts }) => local(ts))).toEqual([31, 1, 2, 3].map((d, i) => [i === 0 ? 10 : 11, d, 0, 0]))
|
|
129
|
+
expect(result[2].ts - result[1].ts).toBe(25 * 3600)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('keeps later midnights where DST skips one', () => {
|
|
133
|
+
// Chile springs forward at midnight on Sep 6 2026; that day starts at 01:00.
|
|
134
|
+
process.env.TZ = 'America/Santiago'
|
|
135
|
+
try {
|
|
136
|
+
const result = alignedTimeAxisTicks(at(9, 4, 12), at(9, 11, 0, 30), 7)!
|
|
137
|
+
expect(result.map(({ ts }) => local(ts))).toEqual([
|
|
138
|
+
[9, 5, 0, 0], [9, 6, 1, 0], [9, 7, 0, 0], [9, 8, 0, 0], [9, 9, 0, 0], [9, 10, 0, 0], [9, 11, 0, 0],
|
|
139
|
+
])
|
|
140
|
+
expect(result[1].label).toBe(day(result[1].ts))
|
|
141
|
+
} finally {
|
|
142
|
+
process.env.TZ = 'America/New_York'
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('takes a coarser step when the caller says labels collide, and gives up below two ticks', () => {
|
|
147
|
+
const daily = alignedTimeAxisTicks(at(10, 21, 2, 48), at(10, 28, 2, 48), 7, (ticks) => ticks.length <= 4)!
|
|
148
|
+
expect(daily.length).toBeLessThanOrEqual(4)
|
|
149
|
+
expect(alignedTimeAxisTicks(at(10, 21, 2, 48), at(10, 28, 2, 48), 7, () => false)).toBeNull()
|
|
150
|
+
expect(alignedTimeAxisTicks(at(10, 21, 2, 48), at(10, 22, 12), 1)).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('names the year on axes and tooltips spanning most of a year or more', () => {
|
|
155
|
+
const twoYears = ticks(at(10, 21, 12), at(10, 21, 12) + 730 * 86_400, 6)
|
|
156
|
+
const withYear = (ts: number) => new Date(ts * 1000).toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' })
|
|
157
|
+
expect(formatTimeAxisLabels(twoYears, 6)).toEqual(twoYears.map(withYear))
|
|
158
|
+
expect(new Set(formatTimeAxisLabels(twoYears, 6)).size).toBe(twoYears.length)
|
|
159
|
+
expect(datesNeedYear(730 * 86_400)).toBe(true)
|
|
160
|
+
expect(datesNeedYear(30 * 86_400)).toBe(false)
|
|
161
|
+
expect(formatFullTimestamp(twoYears[0], true)).toContain(String(new Date(twoYears[0] * 1000).toLocaleDateString([], { year: 'numeric' })))
|
|
162
|
+
expect(formatTimeAxisLabels(ticks(at(10, 21, 2, 48), at(10, 28, 2, 48), 6), 6).some((label) => label.includes('2026'))).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('estimates wide characters at a full em', () => {
|
|
166
|
+
expect(labelWidthEm('Oct 21')).toBeCloseTo(3.6)
|
|
167
|
+
expect(labelWidthEm('10月21日')).toBeCloseTo(4 * 0.6 + 2 * 1)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('formats tooltips with the full date and time', () => {
|
|
171
|
+
const ts = at(10, 21, 14, 5)
|
|
172
|
+
const label = formatFullTimestamp(ts)
|
|
173
|
+
expect(label).toContain(day(ts))
|
|
174
|
+
expect(label).toContain(new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }))
|
|
175
|
+
})
|
|
176
|
+
})
|
|
@@ -59,3 +59,121 @@ export function formatTimestamp(unix: number): string {
|
|
|
59
59
|
const d = new Date(unix * 1000)
|
|
60
60
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
const DAY_SECONDS = 24 * 60 * 60
|
|
64
|
+
|
|
65
|
+
// Past this span two instants can share a month and day, so dates name the year.
|
|
66
|
+
const YEAR_SPAN_SECONDS = 300 * DAY_SECONDS
|
|
67
|
+
|
|
68
|
+
/** Whether dates on a chart spanning this many seconds should name the year. */
|
|
69
|
+
export function datesNeedYear(spanSeconds: number): boolean {
|
|
70
|
+
return spanSeconds > YEAR_SPAN_SECONDS
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatDay(unix: number, withYear = false): string {
|
|
74
|
+
return new Date(unix * 1000).toLocaleDateString([], { ...(withYear && { year: 'numeric' }), month: 'short', day: 'numeric' })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Full local date and time of one instant, for tooltips; `withYear` for charts spanning most of a year or more. */
|
|
78
|
+
export function formatFullTimestamp(unix: number, withYear = false): string {
|
|
79
|
+
return new Date(unix * 1000).toLocaleString([], {
|
|
80
|
+
...(withYear && { year: 'numeric' }),
|
|
81
|
+
month: 'short',
|
|
82
|
+
day: 'numeric',
|
|
83
|
+
hour: '2-digit',
|
|
84
|
+
minute: '2-digit',
|
|
85
|
+
second: '2-digit',
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Labels for evenly spaced time-axis ticks given in ascending unix seconds.
|
|
91
|
+
* A span of a day or less shows the time only (`formatTimestamp`). A longer
|
|
92
|
+
* span names the day (and the year past about ten months): a tick that starts a new local day
|
|
93
|
+
* reads "Oct 21 02:00 PM" and the rest show the time; when that is wider than
|
|
94
|
+
* `maxWidth` (in ems, see `labelWidthEm`), ticks that start a day show the day
|
|
95
|
+
* alone and the rest stay blank, so neighbouring labels never collide.
|
|
96
|
+
*/
|
|
97
|
+
export function formatTimeAxisLabels(ticks: number[], maxWidth = Infinity): string[] {
|
|
98
|
+
if (ticks.length === 0) return []
|
|
99
|
+
const span = ticks[ticks.length - 1] - ticks[0]
|
|
100
|
+
if (span <= DAY_SECONDS) return ticks.map(formatTimestamp)
|
|
101
|
+
|
|
102
|
+
const withYear = datesNeedYear(span)
|
|
103
|
+
const days = ticks.map((ts) => formatDay(ts, withYear))
|
|
104
|
+
const startsDay = days.map((day, i) => i === 0 || day !== days[i - 1])
|
|
105
|
+
const mixed = ticks.map((ts, i) => (startsDay[i] ? `${days[i]} ${formatTimestamp(ts)}` : formatTimestamp(ts)))
|
|
106
|
+
if (Math.max(...mixed.map(labelWidthEm)) <= maxWidth) return mixed
|
|
107
|
+
return days.map((day, i) => (startsDay[i] ? day : ''))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Estimated rendered width of a monospace label in ems: about 0.6em per
|
|
112
|
+
* character, a full em for wide (CJK, fullwidth) characters.
|
|
113
|
+
*/
|
|
114
|
+
export function labelWidthEm(label: string): number {
|
|
115
|
+
let width = 0
|
|
116
|
+
for (const ch of label) width += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch) ? 1 : 0.6
|
|
117
|
+
return width
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface TimeAxisTick {
|
|
121
|
+
ts: number
|
|
122
|
+
label: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const HOUR_SECONDS = 60 * 60
|
|
126
|
+
// Sub-day steps align to local clock hours; day steps to local midnights.
|
|
127
|
+
const ALIGNED_STEPS_HOURS = [6, 12, 24, 48, 72, 168, 336, 672]
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Calendar-aligned ticks for a window longer than a day: local midnights
|
|
131
|
+
* labelled with the day ("Oct 21"), and, when the window is short enough for
|
|
132
|
+
* 6- or 12-hour steps, the hours between labelled with the time. Uses the
|
|
133
|
+
* smallest step that keeps the tick count within `maxTicks` and passes `fits`
|
|
134
|
+
* (the caller's check that neighbouring labels don't collide). Returns null
|
|
135
|
+
* for a window of a day or less, or when fewer than two ticks would fit, so
|
|
136
|
+
* the caller keeps evenly spaced ticks.
|
|
137
|
+
*/
|
|
138
|
+
export function alignedTimeAxisTicks(
|
|
139
|
+
minTs: number,
|
|
140
|
+
maxTs: number,
|
|
141
|
+
maxTicks: number,
|
|
142
|
+
fits: (ticks: TimeAxisTick[]) => boolean = () => true,
|
|
143
|
+
): TimeAxisTick[] | null {
|
|
144
|
+
if (maxTs - minTs <= DAY_SECONDS || maxTicks < 2) return null
|
|
145
|
+
for (const stepHours of ALIGNED_STEPS_HOURS) {
|
|
146
|
+
if ((maxTs - minTs) / HOUR_SECONDS / stepHours > maxTicks + 1) continue
|
|
147
|
+
const ticks = alignedTicks(minTs, maxTs, stepHours)
|
|
148
|
+
if (ticks.length > maxTicks) continue
|
|
149
|
+
if (ticks.length < 2) return null
|
|
150
|
+
const labelled = ticks.map(({ ts, startsDay }) => ({ ts, label: startsDay ? formatDay(ts) : formatTimestamp(ts) }))
|
|
151
|
+
if (fits(labelled)) return labelled
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function alignedTicks(minTs: number, maxTs: number, stepHours: number): { ts: number; startsDay: boolean }[] {
|
|
157
|
+
const out: { ts: number; startsDay: boolean }[] = []
|
|
158
|
+
const start = new Date(minTs * 1000)
|
|
159
|
+
const stepDays = stepHours / 24
|
|
160
|
+
// Each day's start is built from its date: where a DST change skips
|
|
161
|
+
// midnight the Date lands on 01:00, and stepping with setDate would carry
|
|
162
|
+
// that hour into every later day.
|
|
163
|
+
for (let offset = 0; ; offset++) {
|
|
164
|
+
const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate() + offset)
|
|
165
|
+
if (cursor.getTime() / 1000 > maxTs) break
|
|
166
|
+
if (stepHours < 24) {
|
|
167
|
+
for (let hour = 0; hour < 24; hour += stepHours) {
|
|
168
|
+
const ts = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate(), hour).getTime() / 1000
|
|
169
|
+
if (ts >= minTs && ts <= maxTs) out.push({ ts, startsDay: hour === 0 })
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
// Index days by calendar date so the chosen midnights stay put as the window slides.
|
|
173
|
+
const dayIndex = Math.round(Date.UTC(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()) / (DAY_SECONDS * 1000))
|
|
174
|
+
const ts = cursor.getTime() / 1000
|
|
175
|
+
if (dayIndex % stepDays === 0 && ts >= minTs) out.push({ ts, startsDay: true })
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out
|
|
179
|
+
}
|
|
@@ -113,6 +113,38 @@ describe('GitOpsChangesView expanded Argo row', () => {
|
|
|
113
113
|
expect(html).not.toContain('Sync the application to create it')
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
+
test.each([
|
|
117
|
+
['ManualDrift', 'The render includes none of the resources this Application manages.', 'Manual'],
|
|
118
|
+
['AutoSyncBlockedEmpty', 'A pruning sync could delete every listed resource.', 'Auto · prune'],
|
|
119
|
+
['SharedResourceWarning', 'Resolve shared ownership before syncing.', 'Manual'],
|
|
120
|
+
['SelfHealDisabled', 'Resolve shared ownership before syncing.', 'Auto'],
|
|
121
|
+
['Failed', 'Fix the comparison error and refresh before syncing.', 'Manual'],
|
|
122
|
+
])('defers resource sync/create advice to the %s hazard', (reason, hazard, autoSyncMode) => {
|
|
123
|
+
const data = insight(false)
|
|
124
|
+
data.summary.autoSyncMode = autoSyncMode
|
|
125
|
+
data.issues = [{ severity: 'warning', scope: 'operation', reason, message: 'Sync warning', hazard }]
|
|
126
|
+
data.changes = [
|
|
127
|
+
{ ...change, drift: undefined },
|
|
128
|
+
{ ...change, ref: { ...change.ref, name: 'missing' }, missing: true, hasLive: false, health: 'Missing', drift: undefined },
|
|
129
|
+
{ ...change, ref: { ...change.ref, name: 'health-missing' }, sync: 'Unknown', health: 'Missing', drift: undefined },
|
|
130
|
+
]
|
|
131
|
+
const html = renderToStaticMarkup(<GitOpsChangesView insight={data} />)
|
|
132
|
+
expect(count(html, 'See the sync warning above before syncing this Application.')).toBe(3)
|
|
133
|
+
expect(html).not.toContain('Click Sync')
|
|
134
|
+
expect(html).not.toContain('click Sync to force it')
|
|
135
|
+
expect(html).not.toContain('Auto-sync should reconcile')
|
|
136
|
+
expect(html).not.toContain('Sync the application to create it')
|
|
137
|
+
expect(html).not.toContain('Sync to create it')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('keeps ordinary sync advice when there is no hazard', () => {
|
|
141
|
+
const data = insight(false)
|
|
142
|
+
data.summary.autoSyncMode = 'Manual'
|
|
143
|
+
const html = renderToStaticMarkup(<GitOpsChangesView insight={data} />)
|
|
144
|
+
expect(html).toContain('Click Sync to apply the desired state.')
|
|
145
|
+
expect(html).not.toContain('See the sync warning above')
|
|
146
|
+
})
|
|
147
|
+
|
|
116
148
|
test("hands the last-applied list to the Argo diff instead of rendering it alongside", () => {
|
|
117
149
|
let passed: GitOpsDrift | undefined
|
|
118
150
|
const html = renderToStaticMarkup(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronUp, CircleAlert, Clock3, GitBranch, GitCommit, Info, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
|
2
|
+
import { IssueGuidance } from '../../issues/IssueGuidance'
|
|
2
3
|
import { PaneLoader } from '../../ui/PaneLoader'
|
|
3
4
|
import { clsx } from 'clsx'
|
|
4
5
|
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
@@ -359,7 +360,7 @@ export function GitOpsIssuesBand({
|
|
|
359
360
|
<GitOpsHistoricalIssuesDisclosure operationFailure={operationFailure} others={others} onSelectIssue={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />
|
|
360
361
|
) : (
|
|
361
362
|
<>
|
|
362
|
-
{operationFailure && <GitOpsFailureCard issue={operationFailure} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
|
|
363
|
+
{operationFailure && <GitOpsFailureCard issue={operationFailure} emptyRenderGuardShown={others[0]?.reason === 'AutoSyncBlockedEmpty'} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
|
|
363
364
|
{others.length > 0 && <GitOpsCompactIssueStack issues={others} onSelectIssue={onSelectIssue} />}
|
|
364
365
|
</>
|
|
365
366
|
)}
|
|
@@ -383,7 +384,7 @@ function GitOpsLifecycleBanner({ issue }: { issue: GitOpsIssue }) {
|
|
|
383
384
|
</div>
|
|
384
385
|
<p className="mt-1 text-[13px] text-theme-text-secondary">{issue.message}</p>
|
|
385
386
|
{issue.cause && <p className="mt-1 text-[12px] text-orange-300/90">{issue.cause}</p>}
|
|
386
|
-
{issue
|
|
387
|
+
<IssueNextStep issue={issue} className="mt-1" />
|
|
387
388
|
</div>
|
|
388
389
|
</div>
|
|
389
390
|
</div>
|
|
@@ -431,7 +432,7 @@ function GitOpsHistoricalIssuesDisclosure({
|
|
|
431
432
|
</button>
|
|
432
433
|
<Collapse open={expanded}>
|
|
433
434
|
<div className="border-t border-theme-border">
|
|
434
|
-
{operationFailure && <GitOpsFailureCard issue={operationFailure} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
|
|
435
|
+
{operationFailure && <GitOpsFailureCard issue={operationFailure} emptyRenderGuardShown={others[0]?.reason === 'AutoSyncBlockedEmpty'} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
|
|
435
436
|
{others.length > 0 && <GitOpsCompactIssueStack issues={others} onSelectIssue={onSelectIssue} />}
|
|
436
437
|
</div>
|
|
437
438
|
</Collapse>
|
|
@@ -440,15 +441,18 @@ function GitOpsHistoricalIssuesDisclosure({
|
|
|
440
441
|
}
|
|
441
442
|
|
|
442
443
|
// Structured failure card. One unit, owns the failure narrative end-to-end:
|
|
443
|
-
// title (parsed cause when recognized, falls back to raw reason),
|
|
444
|
-
// resource, retry posture, raw controller
|
|
444
|
+
// title (parsed cause when recognized, falls back to raw reason), sync
|
|
445
|
+
// hazard, cause, next step, affected resource, retry posture, raw controller
|
|
446
|
+
// error in a collapsed details.
|
|
445
447
|
function GitOpsFailureCard({
|
|
446
448
|
issue,
|
|
449
|
+
emptyRenderGuardShown,
|
|
447
450
|
onSelect,
|
|
448
451
|
onRemediate,
|
|
449
452
|
remediationPending,
|
|
450
453
|
}: {
|
|
451
454
|
issue: GitOpsIssue
|
|
455
|
+
emptyRenderGuardShown: boolean
|
|
452
456
|
onSelect?: (issue: GitOpsIssue) => void
|
|
453
457
|
// onRemediate fires when the contextual fix button is clicked. The host
|
|
454
458
|
// app (web/) dispatches on issue.remediation.kind to run the right
|
|
@@ -492,7 +496,16 @@ function GitOpsFailureCard({
|
|
|
492
496
|
<span className="text-[11px] text-theme-text-tertiary">retried {issue.retryCount}×</span>
|
|
493
497
|
)}
|
|
494
498
|
</div>
|
|
499
|
+
{/* Ahead of the body: a cause can end in its own sync advice
|
|
500
|
+
("so Argo creates it on sync"), which the hazard overrides. */}
|
|
501
|
+
{issue.hazard && (
|
|
502
|
+
<div className={clsx('mt-2 flex items-start gap-1.5 rounded border px-2 py-1.5 text-[12px] font-medium', SEVERITY_BADGE.warning)}>
|
|
503
|
+
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
504
|
+
<p className="min-w-0">{emptyRenderGuardShown ? 'See the empty-render guard below before syncing.' : issue.hazard}</p>
|
|
505
|
+
</div>
|
|
506
|
+
)}
|
|
495
507
|
{body && <p className="mt-1 text-[13px] text-theme-text-secondary">{body}</p>}
|
|
508
|
+
<IssueNextStep issue={issue} className="mt-1" />
|
|
496
509
|
{ref && (
|
|
497
510
|
<dl className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[12px]">
|
|
498
511
|
<div className="flex gap-1.5">
|
|
@@ -637,10 +650,13 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
|
|
|
637
650
|
)}
|
|
638
651
|
{canExpand && <CollapseChevron open={expanded} className="h-3.5 w-3.5" />}
|
|
639
652
|
</div>
|
|
640
|
-
{headline.cause && (
|
|
641
|
-
<p className="truncate pl-[22px] text-[11px] text-theme-text-tertiary">{headline.cause}</p>
|
|
642
|
-
)}
|
|
643
653
|
</button>
|
|
654
|
+
{(headline.cause || headline.action) && (
|
|
655
|
+
<div className="px-4 pb-2 pl-[38px]">
|
|
656
|
+
{headline.cause && <p className="text-[11px] text-theme-text-tertiary">{headline.cause}</p>}
|
|
657
|
+
<IssueNextStep issue={headline} />
|
|
658
|
+
</div>
|
|
659
|
+
)}
|
|
644
660
|
{canExpand && (
|
|
645
661
|
<Collapse open={expanded}>
|
|
646
662
|
<div className="divide-y divide-theme-border border-t border-theme-border bg-theme-base/40">
|
|
@@ -650,33 +666,34 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
|
|
|
650
666
|
const actionable = !!(onSelectIssue && ref)
|
|
651
667
|
const rawMessage = issue.rawMessage && issue.rawMessage !== issue.message ? issue.rawMessage : ''
|
|
652
668
|
return (
|
|
653
|
-
<
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
669
|
+
<div key={`${issue.reason}-${index}`}>
|
|
670
|
+
<button
|
|
671
|
+
type="button"
|
|
672
|
+
onClick={() => actionable && onSelectIssue?.(issue)}
|
|
673
|
+
disabled={!actionable}
|
|
674
|
+
className={clsx(
|
|
675
|
+
'group flex w-full items-start gap-2 px-4 py-2 text-left text-xs transition-colors',
|
|
676
|
+
actionable ? 'hover:bg-theme-hover/50' : 'cursor-default',
|
|
677
|
+
)}
|
|
678
|
+
>
|
|
679
|
+
{t.icon}
|
|
680
|
+
<div className="min-w-0 flex-1">
|
|
681
|
+
<div className="flex items-center gap-2">
|
|
682
|
+
<span className={clsx('font-semibold', t.text)}>{issue.reason}</span>
|
|
683
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{issue.scope}</span>
|
|
684
|
+
</div>
|
|
685
|
+
<p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
|
|
686
|
+
{issue.cause && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.cause}</p>}
|
|
687
|
+
{rawMessage && <p className="mt-0.5 break-words font-mono text-[11px] text-theme-text-tertiary">{rawMessage}</p>}
|
|
668
688
|
</div>
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
</span>
|
|
678
|
-
)}
|
|
679
|
-
</button>
|
|
689
|
+
{actionable && ref && (
|
|
690
|
+
<span className="shrink-0 self-center text-[11px] font-medium text-theme-text-secondary opacity-70 transition-opacity group-hover:opacity-100">
|
|
691
|
+
Open {refText(ref)} →
|
|
692
|
+
</span>
|
|
693
|
+
)}
|
|
694
|
+
</button>
|
|
695
|
+
<IssueNextStep issue={issue} className="px-4 pb-2 pl-[38px]" />
|
|
696
|
+
</div>
|
|
680
697
|
)
|
|
681
698
|
})}
|
|
682
699
|
</div>
|
|
@@ -686,6 +703,13 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
|
|
|
686
703
|
)
|
|
687
704
|
}
|
|
688
705
|
|
|
706
|
+
// IssueNextStep is an issue's next step, styled alike on every surface of the
|
|
707
|
+
// band. Wrapped, never truncated: a next step often ends in the caveat that
|
|
708
|
+
// makes it safe (what not to sync), which truncation would cut.
|
|
709
|
+
function IssueNextStep({ issue, className }: { issue: GitOpsIssue; className?: string }) {
|
|
710
|
+
return <IssueGuidance action={issue.action} primary={issue.actionPrimary} secondaryTitle={issue.actionSecondaryTitle} secondary={issue.actionSecondary} ordered={issue.reason === 'SharedResourceWarning'} className={clsx('text-[11px] text-theme-text-tertiary', className)} />
|
|
711
|
+
}
|
|
712
|
+
|
|
689
713
|
// firstSentence/remainderAfterFirstSentence split a string at the first
|
|
690
714
|
// sentence boundary so the failure card can render "headline + body" from a
|
|
691
715
|
// single source string. Falls back to the whole string when there's no
|
|
@@ -865,6 +889,7 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
|
|
|
865
889
|
// honest about what it's NOT checking. Neutral note, not an alarm.
|
|
866
890
|
const ignoredDiffs = insight.summary.ignoredDifferences
|
|
867
891
|
const isArgoRoot = insight.summary.tool === 'argocd' && insight.summary.kind === 'Application'
|
|
892
|
+
const syncHazard = insight.issues?.find((issue) => issue.hazard)?.hazard
|
|
868
893
|
// Full-diff affordance is offered only when the backend reports the Argo CD
|
|
869
894
|
// integration is connected for this app AND the host wired the loader.
|
|
870
895
|
const argoDiffAvailable = isArgoRoot && !!insight.capabilities?.argoDiffAvailable && !!renderResourceDiff
|
|
@@ -1098,9 +1123,9 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
|
|
|
1098
1123
|
const rowKey = refKey(change.ref)
|
|
1099
1124
|
const focused = focusKey === rowKey
|
|
1100
1125
|
const explanation = change.missing
|
|
1101
|
-
? missingResourceMessage(change.sync)
|
|
1126
|
+
? missingResourceMessage(change.sync, { hazard: syncHazard })
|
|
1102
1127
|
: !change.syncError && !change.message
|
|
1103
|
-
? explainChangeStatus(change.sync, change.health, insight.summary)
|
|
1128
|
+
? explainChangeStatus(change.sync, change.health, insight.summary, { hazard: syncHazard })
|
|
1104
1129
|
: ''
|
|
1105
1130
|
const hasInlineDetail = !!(
|
|
1106
1131
|
(change.drift && change.drift.entries.length > 0) ||
|
|
@@ -1275,7 +1300,12 @@ function explainChangeStatus(
|
|
|
1275
1300
|
sync: string | undefined,
|
|
1276
1301
|
health: string | undefined,
|
|
1277
1302
|
summary: GitOpsInsight['summary'],
|
|
1303
|
+
context?: { hazard?: string; rolledBack?: boolean },
|
|
1278
1304
|
): string {
|
|
1305
|
+
if (context?.hazard && (sync === 'OutOfSync' || health === 'Missing')) {
|
|
1306
|
+
return 'See the sync warning above before syncing this Application.'
|
|
1307
|
+
}
|
|
1308
|
+
if (context?.rolledBack && health === 'Missing') return missingResourceMessage(sync, context)
|
|
1279
1309
|
const isAuto = (summary.autoSyncMode ?? '').toLowerCase().startsWith('auto')
|
|
1280
1310
|
const phase = (summary.operationPhase ?? '').toLowerCase()
|
|
1281
1311
|
const inFlight = phase === 'running'
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { GitOpsIssuesBand } from './GitOpsInsightViews'
|
|
4
|
+
import type { GitOpsIssue } from '../../../types/gitops-insights'
|
|
5
|
+
import { SEVERITY_BADGE } from '../../../utils/badge-colors'
|
|
6
|
+
|
|
7
|
+
const guard: GitOpsIssue = {
|
|
8
|
+
severity: 'critical',
|
|
9
|
+
scope: 'condition',
|
|
10
|
+
reason: 'AutoSyncBlockedEmpty',
|
|
11
|
+
message: 'Auto-sync blocked: the render includes none of the managed resources',
|
|
12
|
+
rawMessage: 'Skipping sync attempt to [8088f4c]: auto-sync will wipe out all resources',
|
|
13
|
+
cause: 'Argo CD blocked auto-sync because the render includes none of the resources this Application manages.',
|
|
14
|
+
action: 'The render includes none of the resources this Application manages. A sync with Prune enabled could delete all of them. Prune=false blocks direct Argo pruning; Prune=confirm requires confirmation. Pruning a rendered Namespace deletes its contents, regardless of their Prune options.',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The band's top row is the most severe issue. Its next step must be visible
|
|
18
|
+
// without expanding, or a lower row's "click Sync" is the only advice on screen.
|
|
19
|
+
describe('GitOpsIssuesBand headline', () => {
|
|
20
|
+
it('shows the top issue’s next step when it stands alone', () => {
|
|
21
|
+
const html = renderToString(<GitOpsIssuesBand issues={[guard]} />)
|
|
22
|
+
expect(html).toContain(guard.action)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('shows the top issue’s next step above a collapsed stack', () => {
|
|
26
|
+
const drift: GitOpsIssue = {
|
|
27
|
+
severity: 'warning',
|
|
28
|
+
scope: 'operation',
|
|
29
|
+
reason: 'OutOfSync',
|
|
30
|
+
message: 'Application is OutOfSync.',
|
|
31
|
+
action: 'Review the drift.',
|
|
32
|
+
}
|
|
33
|
+
const html = renderToString(<GitOpsIssuesBand issues={[guard, drift]} />)
|
|
34
|
+
expect(html).toContain(guard.action)
|
|
35
|
+
expect(html.indexOf(guard.action!)).toBeLessThan(html.indexOf(drift.action!))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('renders no next-step line when the top issue has none', () => {
|
|
39
|
+
const html = renderToString(
|
|
40
|
+
<GitOpsIssuesBand issues={[{ severity: 'warning', scope: 'condition', reason: 'X', message: 'only a message' }]} />,
|
|
41
|
+
)
|
|
42
|
+
expect(html).toContain('only a message')
|
|
43
|
+
expect(html.match(/pl-\[22px\]/g)).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Argo's namespace-not-found cause ends in its own sync advice.
|
|
48
|
+
const failedSync: GitOpsIssue = {
|
|
49
|
+
severity: 'critical',
|
|
50
|
+
scope: 'operation',
|
|
51
|
+
reason: 'Failed',
|
|
52
|
+
message: 'one or more objects failed to apply, reason: namespaces demo not found',
|
|
53
|
+
cause: 'The destination namespace does not exist. Create it, or enable CreateNamespace=true in the syncOptions so Argo creates it on sync.',
|
|
54
|
+
action: 'Open Activity for operation details.',
|
|
55
|
+
}
|
|
56
|
+
const causeAdvice = 'so Argo creates it on sync'
|
|
57
|
+
const emptyRenderHazard = 'The render includes none of the resources this Application manages. A sync with Prune enabled could delete all of them. Prune=false blocks direct Argo pruning; Prune=confirm requires confirmation. Pruning a rendered Namespace deletes its contents, regardless of their Prune options. Check the source path, targetRevision, Helm values, and Kustomize or directory include/exclude settings first.'
|
|
58
|
+
const emptyRenderReference = 'See the empty-render guard below before syncing.'
|
|
59
|
+
|
|
60
|
+
describe('GitOpsIssuesBand failure card', () => {
|
|
61
|
+
it('shows the failure’s next step after its cause', () => {
|
|
62
|
+
const html = renderToString(<GitOpsIssuesBand issues={[failedSync]} />)
|
|
63
|
+
expect(html).toContain(failedSync.action)
|
|
64
|
+
expect(html.indexOf(causeAdvice)).toBeLessThan(html.indexOf(failedSync.action!))
|
|
65
|
+
expect(html).not.toContain(SEVERITY_BADGE.warning)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('warns of a sync hazard before the cause’s own sync advice', () => {
|
|
69
|
+
const hazard = emptyRenderHazard
|
|
70
|
+
const html = renderToString(<GitOpsIssuesBand issues={[{ ...failedSync, hazard }]} />)
|
|
71
|
+
const at = html.indexOf(hazard)
|
|
72
|
+
expect(at).toBeGreaterThan(-1)
|
|
73
|
+
expect(at).toBeLessThan(html.indexOf(causeAdvice))
|
|
74
|
+
expect(html.lastIndexOf(SEVERITY_BADGE.warning, at)).toBeGreaterThan(-1)
|
|
75
|
+
expect(html).toContain(failedSync.action)
|
|
76
|
+
expect(html).not.toContain(emptyRenderReference)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it.each([false, true])('points to the empty-render guard in the issue band (terminating=%s)', (terminating) => {
|
|
80
|
+
const html = renderToString(<GitOpsIssuesBand issues={[{ ...failedSync, hazard: emptyRenderHazard }, guard]} terminating={terminating} />)
|
|
81
|
+
expect(html).toContain(emptyRenderReference)
|
|
82
|
+
expect(html).not.toContain(emptyRenderHazard)
|
|
83
|
+
expect(html).toContain(guard.message)
|
|
84
|
+
expect(html).toContain(guard.action)
|
|
85
|
+
expect(html.indexOf(emptyRenderReference)).toBeLessThan(html.indexOf(causeAdvice))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('keeps the full hazard when the guard is behind a collapsed row', () => {
|
|
89
|
+
const comparisonError: GitOpsIssue = { severity: 'critical', scope: 'condition', reason: 'ComparisonError', message: 'Cannot render source.' }
|
|
90
|
+
const html = renderToString(<GitOpsIssuesBand issues={[{ ...failedSync, hazard: emptyRenderHazard }, comparisonError, guard]} />)
|
|
91
|
+
expect(html).toContain(emptyRenderHazard)
|
|
92
|
+
expect(html).not.toContain(emptyRenderReference)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('keeps shared-ownership hazards unchanged', () => {
|
|
96
|
+
const hazard = 'Resolve shared ownership before syncing: some resources are also tracked by Application payments. A successful sync can take over their tracking unless it uses FailOnSharedResource=true. While Application payments still declares them, its next successful sync can take tracking back.'
|
|
97
|
+
const shared: GitOpsIssue = { severity: 'warning', scope: 'condition', reason: 'SharedResourceWarning', message: 'Shared resource' }
|
|
98
|
+
const html = renderToString(<GitOpsIssuesBand issues={[{ ...failedSync, hazard }, shared]} />)
|
|
99
|
+
expect(html).toContain(hazard)
|
|
100
|
+
expect(html).not.toContain(emptyRenderReference)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('shows a remote failure’s destination-cluster instruction', () => {
|
|
104
|
+
const action = 'Create namespace demo on the destination cluster, or add the CreateNamespace=true sync option.'
|
|
105
|
+
const html = renderToString(<GitOpsIssuesBand issues={[{ ...failedSync, action }]} />)
|
|
106
|
+
expect(html).toContain(action)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -30,6 +30,34 @@ describe('confirmed missing resources', () => {
|
|
|
30
30
|
expect(isArgoResourceSyncEligible(item, true)).toBe(true)
|
|
31
31
|
})
|
|
32
32
|
|
|
33
|
+
it('defers creation advice to the sync hazard while preserving stale Synced refresh advice', () => {
|
|
34
|
+
for (const sync of ['OutOfSync', 'Unknown', undefined]) {
|
|
35
|
+
expect(missingResourceMessage(sync, { hazard: 'Resolve shared ownership before syncing.' })).toBe(
|
|
36
|
+
'Not present in the cluster. See the sync warning above before syncing this Application.',
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
expect(missingResourceMessage('Synced', { hazard: 'A pruning sync could delete everything.' })).toContain('Refresh')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('warns that recreating a missing resource can undo a rollback', () => {
|
|
43
|
+
for (const sync of ['OutOfSync', 'Unknown', undefined]) {
|
|
44
|
+
expect(missingResourceMessage(sync, { rolledBack: true })).toBe(
|
|
45
|
+
'Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('prioritizes the sync hazard over rollback advice', () => {
|
|
51
|
+
for (const sync of ['OutOfSync', 'Unknown', undefined]) {
|
|
52
|
+
expect(missingResourceMessage(sync, { hazard: 'Resolve shared tracking.', rolledBack: true })).toBe(
|
|
53
|
+
'Not present in the cluster. See the sync warning above before syncing this Application.',
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
// Argo calling it Synced means the cluster view is stale: refresh, whatever the context.
|
|
57
|
+
expect(missingResourceMessage('Synced', { hazard: 'Resolve shared tracking.', rolledBack: true })).toContain('Refresh the application')
|
|
58
|
+
expect(missingResourceMessage('OutOfSync', {})).toContain('Sync the application')
|
|
59
|
+
})
|
|
60
|
+
|
|
33
61
|
it('asks for a refresh when Argo still reports Synced', () => {
|
|
34
62
|
expect(missingResourceMessage('Synced')).toContain('Refresh')
|
|
35
63
|
expect(missingResourceMessage('Synced')).not.toContain('Sync the application')
|
|
@@ -47,9 +47,11 @@ export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
|
|
|
47
47
|
return normalizeHealthStatus(change.health)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export function missingResourceMessage(sync?: string): string {
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
export function missingResourceMessage(sync?: string, context?: { hazard?: string; rolledBack?: boolean }): string {
|
|
51
|
+
if (sync === 'Synced') return 'Argo reports this resource as synced, but it is not present in the cluster. Refresh the application to check its status.'
|
|
52
|
+
if (context?.hazard) return 'Not present in the cluster. See the sync warning above before syncing this Application.'
|
|
53
|
+
return context?.rolledBack
|
|
54
|
+
? 'Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.'
|
|
53
55
|
: 'Not present in the cluster. Sync the application to create it.'
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { GitOpsIssuesBand } from '../gitops/insights/GitOpsInsightViews'
|
|
6
|
+
import { IssueRow } from './IssuesView'
|
|
7
|
+
import { ResourceIssuesSection } from './ResourceIssuesSection'
|
|
8
|
+
import type { GitOpsIssue } from '../../types/gitops-insights'
|
|
9
|
+
import type { Issue } from './types'
|
|
10
|
+
|
|
11
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
12
|
+
|
|
13
|
+
const primary = 'The render includes none of the resources this Application manages. A sync with Prune enabled could delete all of them. Prune=false blocks direct Argo pruning; Prune=confirm requires confirmation. Pruning a rendered Namespace deletes its contents, regardless of their Prune options. Check the source path, targetRevision, Helm values, and Kustomize or directory include/exclude settings first.'
|
|
14
|
+
const secondary = [
|
|
15
|
+
'If this Application should go away: remove it from the ApplicationSet generator if generated; while the generator still emits it, the ApplicationSet recreates it. Check whether the generated Application survives; its ApplicationSet sync policy may keep it. If it survives, delete it explicitly with the intended cascade choice. For a plain Application, delete the Application. Cleanup follows the cascade/resources finalizer choice and Delete=false / Delete=confirm, not Prune options.',
|
|
16
|
+
'If it should render empty: set syncPolicy.automated.allowEmpty: true in the Application or its ApplicationSet template. This turns off the empty-render guard for all future syncs.',
|
|
17
|
+
]
|
|
18
|
+
const title = 'If removing all managed resources is intended'
|
|
19
|
+
const cause = 'Argo CD blocked auto-sync because the render includes none of the resources this Application manages.'
|
|
20
|
+
const guard: GitOpsIssue = {
|
|
21
|
+
severity: 'critical', scope: 'condition', reason: 'AutoSyncBlockedEmpty',
|
|
22
|
+
message: 'Auto-sync blocked: the render includes none of the managed resources', cause,
|
|
23
|
+
action: [primary, ...secondary].join(' '), actionPrimary: primary,
|
|
24
|
+
actionSecondaryTitle: title, actionSecondary: secondary,
|
|
25
|
+
}
|
|
26
|
+
const issue: Issue = {
|
|
27
|
+
id: 'guard', severity: 'critical', source: 'problem',
|
|
28
|
+
category: 'gitops_sync_failed', category_group: 'gitops', grouping_scope: 'unknown',
|
|
29
|
+
kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'empty',
|
|
30
|
+
reason: guard.reason, message: guard.message, cause,
|
|
31
|
+
action: guard.action, action_primary: primary,
|
|
32
|
+
action_secondary_title: title, action_secondary: secondary,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let root: Root
|
|
36
|
+
let element: HTMLDivElement
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
element = document.createElement('div')
|
|
39
|
+
document.body.appendChild(element)
|
|
40
|
+
root = createRoot(element)
|
|
41
|
+
})
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
await act(async () => root.unmount())
|
|
44
|
+
element.remove()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
function disclosure() {
|
|
48
|
+
const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(title))!
|
|
49
|
+
expect(button).toBeDefined()
|
|
50
|
+
return { button, panel: document.getElementById(button.getAttribute('aria-controls')!)! }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checkPrimaryVisible() {
|
|
54
|
+
for (const text of [primary, cause]) {
|
|
55
|
+
const paragraph = [...element.querySelectorAll('p')].find((p) => p.textContent === text)!
|
|
56
|
+
expect(paragraph).toBeDefined()
|
|
57
|
+
expect(paragraph.closest('[inert]')).toBeNull()
|
|
58
|
+
}
|
|
59
|
+
expect(element.querySelector('button button')).toBeNull()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function checkDisclosure() {
|
|
63
|
+
const { button, panel } = disclosure()
|
|
64
|
+
expect(button.getAttribute('aria-expanded')).toBe('false')
|
|
65
|
+
expect(panel.style.gridTemplateRows).toBe('0fr')
|
|
66
|
+
expect(panel.querySelector('[inert]')).not.toBeNull()
|
|
67
|
+
checkPrimaryVisible()
|
|
68
|
+
await act(async () => button.click())
|
|
69
|
+
expect(button.getAttribute('aria-expanded')).toBe('true')
|
|
70
|
+
expect(panel.style.gridTemplateRows).toBe('1fr')
|
|
71
|
+
expect(panel.querySelector('[inert]')).toBeNull()
|
|
72
|
+
for (const paragraph of secondary) expect(panel.textContent).toContain(paragraph)
|
|
73
|
+
await act(async () => button.click())
|
|
74
|
+
expect(button.getAttribute('aria-expanded')).toBe('false')
|
|
75
|
+
expect(panel.querySelector('[inert]')).not.toBeNull()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('empty-render guidance', () => {
|
|
79
|
+
it('shows primary guidance on the detail band and opens both intended-render paths', async () => {
|
|
80
|
+
await act(async () => root.render(<GitOpsIssuesBand issues={[guard]} />))
|
|
81
|
+
await checkDisclosure()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('keeps the headline guidance independent of the collapsed detail stack', async () => {
|
|
85
|
+
const drift: GitOpsIssue = { severity: 'warning', scope: 'condition', reason: 'OtherWarning', message: 'Another condition' }
|
|
86
|
+
await act(async () => root.render(<GitOpsIssuesBand issues={[guard, drift]} />))
|
|
87
|
+
await checkDisclosure()
|
|
88
|
+
const stack = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes('AutoSyncBlockedEmpty'))!
|
|
89
|
+
expect(stack.getAttribute('aria-expanded')).toBe('false')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('opens guidance on a lower detail row after expanding the stack', async () => {
|
|
93
|
+
const first: GitOpsIssue = { severity: 'critical', scope: 'condition', reason: 'ComparisonError', message: 'Cannot render' }
|
|
94
|
+
await act(async () => root.render(<GitOpsIssuesBand issues={[first, guard]} />))
|
|
95
|
+
const stack = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes('ComparisonError'))!
|
|
96
|
+
await act(async () => stack.click())
|
|
97
|
+
await checkDisclosure()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it.each([false, true])('shows split guidance on the cluster Issues row (open=%s)', async (open) => {
|
|
101
|
+
await act(async () => root.render(<IssueRow issue={issue} open={open} onToggle={() => {}} />))
|
|
102
|
+
await checkDisclosure()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('shows primary guidance and disclosure in the resource drawer without expanding its issue', async () => {
|
|
106
|
+
await act(async () => root.render(<ResourceIssuesSection issues={[issue]} />))
|
|
107
|
+
await checkDisclosure()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('shows complete action when presentation fields are absent', async () => {
|
|
111
|
+
const { actionPrimary: _primary, actionSecondaryTitle: _title, actionSecondary: _secondary, ...plain } = guard
|
|
112
|
+
await act(async () => root.render(<GitOpsIssuesBand issues={[plain]} />))
|
|
113
|
+
expect(element.textContent).toContain(guard.action)
|
|
114
|
+
expect(element.querySelectorAll('button[aria-controls]')).toHaveLength(0)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const moveSteps = [
|
|
119
|
+
"Check this Application's sync options for FailOnSharedResource=true. If set, the sync will refuse the takeover. Stop unless taking it over is intended and allowed.",
|
|
120
|
+
"Note the resource's live UID.",
|
|
121
|
+
'Sync this Application.',
|
|
122
|
+
]
|
|
123
|
+
const movePrimary = "To leave it with Application payments, remove it from this Application's source."
|
|
124
|
+
const moveTitle = 'To move it to this Application'
|
|
125
|
+
const move: GitOpsIssue = {
|
|
126
|
+
severity: 'warning', scope: 'condition', reason: 'SharedResourceWarning', message: 'Also tracked by payments',
|
|
127
|
+
action: `${movePrimary} ${moveTitle}: ${moveSteps.join(' ')}`, actionPrimary: movePrimary,
|
|
128
|
+
actionSecondaryTitle: moveTitle, actionSecondary: moveSteps,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe('shared-resource move guidance', () => {
|
|
132
|
+
it.each(['detail', 'cluster'] as const)('renders numbered steps on the %s surface', async (surface) => {
|
|
133
|
+
const row: Issue = { ...issue, reason: move.reason, message: move.message, action: move.action,
|
|
134
|
+
action_primary: movePrimary, action_secondary_title: moveTitle, action_secondary: moveSteps }
|
|
135
|
+
await act(async () => root.render(surface === 'detail'
|
|
136
|
+
? <GitOpsIssuesBand issues={[move]} />
|
|
137
|
+
: <IssueRow issue={row} open={true} onToggle={() => {}} />))
|
|
138
|
+
const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(moveTitle))!
|
|
139
|
+
expect(button).toBeDefined()
|
|
140
|
+
await act(async () => button.click())
|
|
141
|
+
const panel = document.getElementById(button.getAttribute('aria-controls')!)!
|
|
142
|
+
expect([...panel.querySelectorAll('ol > li')].map((li) => li.textContent)).toEqual(moveSteps)
|
|
143
|
+
expect(panel.querySelector('[inert]')).toBeNull()
|
|
144
|
+
expect(element.textContent).toContain(movePrimary)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('cluster sync hazards', () => {
|
|
149
|
+
const hazard = 'Resolve shared ownership before syncing: another Application can take tracking back.'
|
|
150
|
+
it.each([undefined, { nextStep: 'Host next step', cause: 'Host cause' }])('keeps a warning ahead of any next-step source (%j)', async (diagnosisSource) => {
|
|
151
|
+
await act(async () => root.render(<IssueRow
|
|
152
|
+
issue={{ ...issue, action_primary: undefined, action_secondary: undefined, action_secondary_title: undefined, hazard }}
|
|
153
|
+
open={true} onToggle={() => {}} diagnosisSource={diagnosisSource} />))
|
|
154
|
+
const warning = [...element.querySelectorAll('p')].find((p) => p.textContent === hazard)!
|
|
155
|
+
expect(warning).toBeDefined()
|
|
156
|
+
expect(warning.closest('.text-warning-text')).not.toBeNull()
|
|
157
|
+
expect(warning.closest('[inert]')).toBeNull()
|
|
158
|
+
expect(element.textContent!.indexOf(hazard)).toBeLessThan(element.textContent!.indexOf(diagnosisSource?.nextStep ?? primary))
|
|
159
|
+
if (diagnosisSource) expect(element.textContent).toContain(diagnosisSource.nextStep)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('shows a hazard on a collapsed row with no split guidance or action', async () => {
|
|
163
|
+
await act(async () => root.render(<IssueRow
|
|
164
|
+
issue={{ ...issue, action: undefined, action_primary: undefined, action_secondary: undefined, action_secondary_title: undefined, hazard }}
|
|
165
|
+
open={false} onToggle={() => {}} diagnosisSource={{ nextStep: 'Host next step' }} />))
|
|
166
|
+
const warning = [...element.querySelectorAll('p')].find((p) => p.textContent === hazard)!
|
|
167
|
+
expect(warning).toBeDefined()
|
|
168
|
+
expect(warning.closest('[inert]')).toBeNull()
|
|
169
|
+
expect(warning.closest('.text-warning-text')).not.toBeNull()
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('hazard text retained only once in presentation', () => {
|
|
174
|
+
it.each([false, true])('keeps empty-render guidance and one warning (open=%s)', async (open) => {
|
|
175
|
+
await act(async () => root.render(<IssueRow issue={{ ...issue, hazard: primary }} open={open} onToggle={() => {}} />))
|
|
176
|
+
expect([...element.querySelectorAll('p')].filter((p) => p.textContent === primary)).toHaveLength(1)
|
|
177
|
+
await checkDisclosure()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it.each(['hazard only', 'appended hazard'])('keeps one warning for %s actions', async (mode) => {
|
|
181
|
+
const hazard = 'Sync can take over shared tracking.'
|
|
182
|
+
const nextStep = "Open the application's sync operation details."
|
|
183
|
+
const action = mode === 'hazard only' ? hazard : `${nextStep} ${hazard}`
|
|
184
|
+
await act(async () => root.render(<IssueRow issue={{ ...issue, hazard, action,
|
|
185
|
+
action_primary: undefined, action_secondary: undefined, action_secondary_title: undefined }} open={true} onToggle={() => {}} />))
|
|
186
|
+
expect(element.textContent!.split(hazard)).toHaveLength(2)
|
|
187
|
+
if (mode === 'hazard only') expect(element.textContent).not.toContain('Next step')
|
|
188
|
+
else expect(element.textContent).toContain(nextStep)
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AlertTriangle } from 'lucide-react'
|
|
2
|
+
import { Disclosure } from '../ui/Disclosure'
|
|
3
|
+
|
|
4
|
+
export function issueGuidanceSummary(action?: string, primary?: string, hazard?: string) {
|
|
5
|
+
const summary = primary || action
|
|
6
|
+
if (!hazard || !summary) return summary
|
|
7
|
+
if (summary === hazard) return undefined
|
|
8
|
+
return summary.endsWith(` ${hazard}`) ? summary.slice(0, -hazard.length - 1) : summary
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function IssueGuidance({ action, primary, secondaryTitle, secondary, ordered, hazard, className }: {
|
|
12
|
+
action?: string
|
|
13
|
+
/** Replaces a supplied action; primary alone renders no action text. */
|
|
14
|
+
primary?: string
|
|
15
|
+
/** Paired with secondary; no disclosure is shown without both. */
|
|
16
|
+
secondaryTitle?: string
|
|
17
|
+
/** Paired with secondaryTitle; no disclosure is shown without both. */
|
|
18
|
+
secondary?: string[]
|
|
19
|
+
ordered?: boolean
|
|
20
|
+
hazard?: string
|
|
21
|
+
className?: string
|
|
22
|
+
}) {
|
|
23
|
+
if (!action && !hazard && !(secondaryTitle && secondary?.length)) return null
|
|
24
|
+
return (
|
|
25
|
+
<div className={className}>
|
|
26
|
+
{hazard && (
|
|
27
|
+
<div className="mb-2 flex items-start gap-2 text-warning-text">
|
|
28
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
29
|
+
<p>{hazard}</p>
|
|
30
|
+
</div>
|
|
31
|
+
)}
|
|
32
|
+
{action && <p>{primary || action}</p>}
|
|
33
|
+
{secondaryTitle && secondary && (
|
|
34
|
+
<Disclosure summary={secondaryTitle} className="mt-2" summaryClassName="font-medium">
|
|
35
|
+
{ordered ? (
|
|
36
|
+
<ol className="list-decimal space-y-2 pl-5 pt-2">
|
|
37
|
+
{secondary.map((step) => <li key={step}>{step}</li>)}
|
|
38
|
+
</ol>
|
|
39
|
+
) : (
|
|
40
|
+
<div className="space-y-2 pt-2">
|
|
41
|
+
{secondary.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
</Disclosure>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { IssueGuidance, issueGuidanceSummary } from './IssueGuidance'
|
|
1
2
|
import { summarizeSchedulerMessage } from '../resources/resource-utils'
|
|
2
3
|
import { useMemo, useState, type ComponentType, type ReactNode } from 'react';
|
|
3
4
|
import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, Eraser, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
|
|
4
|
-
import {
|
|
5
|
+
import { CardSection, ClusterName, Disclosure, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
|
|
5
6
|
import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse';
|
|
6
7
|
import { Tooltip } from '../ui/Tooltip';
|
|
7
8
|
import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
|
|
@@ -368,6 +369,15 @@ export function IssueRow({
|
|
|
368
369
|
</div>
|
|
369
370
|
</div>
|
|
370
371
|
|
|
372
|
+
{(issue.action_primary || issue.hazard) && (
|
|
373
|
+
<Collapse open={!open} unmountOnExit>
|
|
374
|
+
<div className="px-4 pb-3 pl-6 text-xs">
|
|
375
|
+
{issue.cause && <p className="mb-1 text-theme-text-primary">{issue.cause}</p>}
|
|
376
|
+
<IssueGuidance hazard={issue.hazard} action={issue.action_primary ? issueGuidanceSummary(issue.action, issue.action_primary, issue.hazard) : undefined} secondaryTitle={issue.action_secondary_title} secondary={issue.action_secondary} ordered={issue.reason === 'SharedResourceWarning'} className="text-theme-text-tertiary" />
|
|
377
|
+
</div>
|
|
378
|
+
</Collapse>
|
|
379
|
+
)}
|
|
380
|
+
|
|
371
381
|
{/* Details unmount after the close transition: a list can hold hundreds
|
|
372
382
|
of rows and the body is the expensive part, so closed rows stay light. */}
|
|
373
383
|
<Collapse open={open} unmountOnExit id={panelId}>
|
|
@@ -509,11 +519,13 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
|
|
|
509
519
|
</>
|
|
510
520
|
) : undefined;
|
|
511
521
|
|
|
522
|
+
const guidanceSummary = issueGuidanceSummary(issue.action, issue.action_primary, issue.hazard);
|
|
512
523
|
const hasNextStep =
|
|
513
|
-
!!issue.
|
|
524
|
+
!!guidanceSummary || !!issue.action_secondary?.length || !!sourceNextStep || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
|
|
514
525
|
|
|
515
526
|
return (
|
|
516
527
|
<div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
|
|
528
|
+
<IssueGuidance hazard={issue.hazard} className="text-sm leading-relaxed" />
|
|
517
529
|
{/* When a parsed cause replaces the detector text, the terse reason code
|
|
518
530
|
(CrashLoopBackOff, FailedMount…) rides the eyebrow — it's the greppable
|
|
519
531
|
identifier operators anchor on, and the collapsed header drops it while
|
|
@@ -547,7 +559,7 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
|
|
|
547
559
|
tone="fix"
|
|
548
560
|
labelExtra={nextStepAttribution ?? undefined}
|
|
549
561
|
>
|
|
550
|
-
{sourceNextStep ??
|
|
562
|
+
{sourceNextStep ?? <IssueGuidance action={guidanceSummary} secondaryTitle={issue.action_secondary_title} secondary={issue.action_secondary} ordered={issue.reason === 'SharedResourceWarning'} className="text-sm leading-relaxed text-theme-text-secondary" />}
|
|
551
563
|
{/* Survives an override: this is a structured one-click fix with an
|
|
552
564
|
apply path, not advice competing with the host's. */}
|
|
553
565
|
{issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
|
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
memberRef,
|
|
16
16
|
issueSortAnchor,
|
|
17
17
|
compareIssueSortAnchors,
|
|
18
|
+
compareIssues,
|
|
18
19
|
} from './types';
|
|
19
20
|
export type { Issue, IssueConditionalCleanup, IssueFinalizerRemoval, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField, IssueRunningPreviousValue, IssueWithheld } from './types';
|
|
20
21
|
export {
|
|
@@ -100,6 +100,7 @@ const CATEGORY_LABEL: Record<string, string> = {
|
|
|
100
100
|
crossplane_reconcile_failed: 'Crossplane reconcile failed',
|
|
101
101
|
termination_stuck: 'Stuck terminating',
|
|
102
102
|
operator_condition_failed: 'Controller reports a problem',
|
|
103
|
+
gitops_resource_warning: 'GitOps resource warning',
|
|
103
104
|
gitops_sync_failed: 'GitOps sync failed',
|
|
104
105
|
gitops_render_failed: 'GitOps render failed',
|
|
105
106
|
gitops_spec_invalid: 'GitOps spec invalid',
|
|
@@ -231,6 +231,16 @@ export interface Issue {
|
|
|
231
231
|
* Server-emitted (omitempty); empty for issues without a parser. */
|
|
232
232
|
cause?: string;
|
|
233
233
|
action?: string;
|
|
234
|
+
/** What the suggested or most obvious action could destroy or take over,
|
|
235
|
+
* such as a GitOps sync that would prune every managed resource. Show it
|
|
236
|
+
* even when replacing cause or action with other guidance; action also
|
|
237
|
+
* contains it. Absence means no warning was supplied, not that the action
|
|
238
|
+
* is safe; older producers omit it. */
|
|
239
|
+
hazard?: string;
|
|
240
|
+
/** Optional presentation split; action always retains the complete guidance. */
|
|
241
|
+
action_primary?: string;
|
|
242
|
+
action_secondary_title?: string;
|
|
243
|
+
action_secondary?: string[];
|
|
234
244
|
remediation_kind?: string;
|
|
235
245
|
remediation_target?: string;
|
|
236
246
|
/** A cleanup that is right only when its condition holds — never part of
|
package/src/types/core.ts
CHANGED
|
@@ -176,6 +176,7 @@ export interface FeatureCapabilities {
|
|
|
176
176
|
cnpgWorkspace?: boolean
|
|
177
177
|
cnpgProtectionSetup?: boolean
|
|
178
178
|
gitopsWriteEvidence?: boolean
|
|
179
|
+
agentSigninCheck?: boolean
|
|
179
180
|
serviceEndpointSlices?: boolean
|
|
180
181
|
trafficRecords?: boolean
|
|
181
182
|
clusterScopedPlaceholder?: boolean
|
|
@@ -124,6 +124,14 @@ export interface GitOpsIssue {
|
|
|
124
124
|
rawMessage?: string
|
|
125
125
|
refs?: GitOpsInsightRef[]
|
|
126
126
|
action?: string
|
|
127
|
+
/** Optional presentation split; action always retains the complete guidance. */
|
|
128
|
+
actionPrimary?: string
|
|
129
|
+
actionSecondaryTitle?: string
|
|
130
|
+
actionSecondary?: string[]
|
|
131
|
+
// Set when syncing would prune every managed
|
|
132
|
+
// resource or take resources over from another Application. Shown as a
|
|
133
|
+
// warning ahead of a failed operation's cause; resource rows defer sync advice to it.
|
|
134
|
+
hazard?: string
|
|
127
135
|
// Plain-English root cause when the message matched a recognized error
|
|
128
136
|
// pattern. Empty for unrecognized messages — UI falls back to the raw message.
|
|
129
137
|
cause?: string
|