@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.18.2",
3
+ "version": "1.19.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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, formatTimestamp } from './format'
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 base = chartLayout(compact, layout === 'dashboard', renderedWidth)
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
- return Array.from({ length: xIntervals + 1 }, (_, i) => {
153
- const ts = minTs + ((maxTs - minTs) / xIntervals) * i
154
- // In the compact layout the two labels sit at the plot edges and would
155
- // otherwise spill past the viewBox.
156
- const anchor: 'start' | 'middle' | 'end' = !(compact || layout === 'dashboard') ? 'middle' : i === 0 ? 'start' : i === xIntervals ? 'end' : 'middle'
157
- return { ts, x: toX(ts), label: formatTimestamp(ts), anchor }
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
- }, [chartData, xIntervals, compact, layout, marginLeft, plotWidth])
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: 240 } : undefined}
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
- {new Date(hoverData.ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
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.action && <p className="mt-1 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
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), affected
444
- // resource, retry posture, raw controller error in a collapsed details.
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
- <button
654
- key={`${issue.reason}-${index}`}
655
- type="button"
656
- onClick={() => actionable && onSelectIssue?.(issue)}
657
- disabled={!actionable}
658
- className={clsx(
659
- 'group flex w-full items-start gap-2 px-4 py-2 text-left text-xs transition-colors',
660
- actionable ? 'hover:bg-theme-hover/50' : 'cursor-default',
661
- )}
662
- >
663
- {t.icon}
664
- <div className="min-w-0 flex-1">
665
- <div className="flex items-center gap-2">
666
- <span className={clsx('font-semibold', t.text)}>{issue.reason}</span>
667
- <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{issue.scope}</span>
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
- <p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
670
- {issue.cause && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.cause}</p>}
671
- {rawMessage && <p className="mt-0.5 break-words font-mono text-[11px] text-theme-text-tertiary">{rawMessage}</p>}
672
- {issue.action && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
673
- </div>
674
- {actionable && ref && (
675
- <span className="shrink-0 self-center text-[11px] font-medium text-theme-text-secondary opacity-70 transition-opacity group-hover:opacity-100">
676
- Open {refText(ref)} →
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
- 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.'
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 { CardBody, CardSection, ClusterName, Disclosure, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
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.action || !!sourceNextStep || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
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 ?? (issue.action ? <CardBody>{issue.action}</CardBody> : null)}
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