@skyhook-io/k8s-ui 1.8.6 → 1.8.8

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.
Files changed (68) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +6 -1
  4. package/src/components/audit/AuditAlerts.tsx +4 -0
  5. package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
  6. package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
  7. package/src/components/audit/AuditFindingsTable.tsx +4 -0
  8. package/src/components/audit/index.ts +1 -0
  9. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  10. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
  11. package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
  12. package/src/components/gitops/GitOpsTableView.tsx +49 -46
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  14. package/src/components/issues/IssuesView.tsx +49 -40
  15. package/src/components/issues/ResourceIssuesSection.tsx +145 -0
  16. package/src/components/issues/diagnostic.ts +86 -0
  17. package/src/components/issues/index.ts +2 -1
  18. package/src/components/issues/issues.test.ts +21 -0
  19. package/src/components/issues/severity.ts +10 -9
  20. package/src/components/issues/types.ts +23 -0
  21. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  22. package/src/components/namespace-switcher/index.ts +6 -0
  23. package/src/components/resources/ResourcesView.tsx +58 -83
  24. package/src/components/resources/cron-to-human.test.ts +41 -0
  25. package/src/components/resources/get-pod-problems.test.ts +18 -0
  26. package/src/components/resources/health-golden.test.ts +66 -0
  27. package/src/components/resources/renderers/JobRenderer.tsx +6 -2
  28. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
  30. package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
  31. package/src/components/resources/renderers/PodRenderer.tsx +28 -9
  32. package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
  33. package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
  34. package/src/components/resources/resource-utils-argo.test.ts +23 -0
  35. package/src/components/resources/resource-utils-argo.ts +5 -1
  36. package/src/components/resources/resource-utils-keda.ts +12 -8
  37. package/src/components/resources/resource-utils.ts +34 -14
  38. package/src/components/scope-pill/ScopePill.tsx +35 -0
  39. package/src/components/scope-pill/index.ts +2 -0
  40. package/src/components/timeline/TimelineList.tsx +27 -1
  41. package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
  42. package/src/components/timeline/shared.tsx +15 -4
  43. package/src/components/topology/K8sResourceNode.tsx +28 -1
  44. package/src/components/topology/TopologyControls.tsx +90 -14
  45. package/src/components/topology/layout.ts +11 -5
  46. package/src/components/ui/FreshnessControl.tsx +153 -0
  47. package/src/components/ui/PaneLoader.tsx +24 -6
  48. package/src/components/ui/SortableTh.tsx +16 -10
  49. package/src/components/ui/Toast.tsx +1 -1
  50. package/src/components/ui/drawer-components.test.tsx +35 -0
  51. package/src/components/ui/drawer-components.tsx +13 -1
  52. package/src/components/ui/index.ts +2 -0
  53. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  54. package/src/components/workload/WorkloadView.tsx +61 -13
  55. package/src/hooks/index.ts +1 -0
  56. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  57. package/src/hooks/useRefreshAnimation.ts +15 -2
  58. package/src/index.ts +8 -0
  59. package/src/types/core.ts +142 -3
  60. package/src/types/gitops-insights.ts +4 -0
  61. package/src/utils/animation.ts +10 -0
  62. package/src/utils/applications.test.ts +55 -1
  63. package/src/utils/applications.ts +28 -7
  64. package/src/utils/badge-colors.ts +7 -0
  65. package/src/utils/format-freshness.test.ts +34 -0
  66. package/src/utils/format.ts +32 -0
  67. package/src/utils/resource-hierarchy.test.ts +51 -0
  68. package/src/utils/resource-hierarchy.ts +7 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.6",
3
+ "version": "1.8.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -85,7 +85,7 @@
85
85
  "yaml": ">=2.0.0"
86
86
  },
87
87
  "devDependencies": {
88
- "@types/node": "^26.0.0",
88
+ "@types/node": "^26.0.1",
89
89
  "@types/react": "^19.2.17",
90
90
  "@types/react-dom": "^19.2.3",
91
91
  "@xterm/addon-fit": "^0.11.0",
@@ -95,7 +95,7 @@
95
95
  "clsx": "^2.1.1",
96
96
  "diff": "^9.0.0",
97
97
  "elkjs": "^0.11.1",
98
- "lucide-react": "^1.16.0",
98
+ "lucide-react": "^1.22.0",
99
99
  "react": "^19.2.7",
100
100
  "react-dom": "^19.2.7",
101
101
  "typescript": "^6.0.2",
@@ -1,4 +1,4 @@
1
- import { useMemo } from 'react'
1
+ import { useMemo, type ReactNode } from 'react'
2
2
  import { type AppRow, buildSingleAppEntry } from '../../utils/applications'
3
3
  import { ApplicationsView } from './ApplicationsView'
4
4
 
@@ -11,9 +11,11 @@ import { ApplicationsView } from './ApplicationsView'
11
11
  export interface ApplicationsListProps {
12
12
  apps: AppRow[]
13
13
  onSelect: (key: string) => void
14
+ /** Leading element in the header actions (e.g. a freshness control). */
15
+ headerActions?: ReactNode
14
16
  }
15
17
 
16
- export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
18
+ export function ApplicationsList({ apps, onSelect, headerActions }: ApplicationsListProps) {
17
19
  // Env tokens this CLUSTER proved (identity classifications on the wire) feed
18
20
  // the namespace heuristic, so sibling-less rows in discovered env namespaces
19
21
  // still label without any hardcoded vocabulary.
@@ -25,6 +27,7 @@ export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
25
27
  variant="single"
26
28
  entries={entries}
27
29
  onSelect={onSelect}
30
+ headerActions={headerActions}
28
31
  title="Applications"
29
32
  description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
30
33
  />
@@ -56,6 +56,7 @@ const HEALTH_TONE: Record<AppHealth, FacetTone> = {
56
56
  unhealthy: 'error',
57
57
  degraded: 'warning',
58
58
  healthy: 'success',
59
+ neutral: 'info', // Idle — sky, calm
59
60
  unknown: 'neutral',
60
61
  }
61
62
 
@@ -96,9 +97,11 @@ export interface ApplicationsViewProps {
96
97
  /** Rendered instead of the built-in EmptyState when there are zero entries
97
98
  * pre-filter (the fleet host injects a coverage/offline-aware empty). */
98
99
  emptySlot?: ReactNode
100
+ /** Leading element in the header actions cluster (e.g. a freshness control). */
101
+ headerActions?: ReactNode
99
102
  }
100
103
 
101
- export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot }: ApplicationsViewProps) {
104
+ export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot, headerActions }: ApplicationsViewProps) {
102
105
  const [textFilter, setTextFilter] = useState('')
103
106
  const [fHealth, setFHealth] = useState<Set<AppHealth>>(new Set())
104
107
  const [fEnv, setFEnv] = useState<Set<string>>(new Set())
@@ -293,10 +296,12 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
293
296
  description={description}
294
297
  actions={
295
298
  <>
299
+ {headerActions}
296
300
  <SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} />
297
301
  {healthTile('unhealthy', 'error')}
298
302
  {healthTile('degraded', 'warning')}
299
303
  {healthTile('healthy', 'success')}
304
+ {healthTile('neutral', 'info')}
300
305
  {healthTile('unknown', 'neutral')}
301
306
  </>
302
307
  }
@@ -5,6 +5,10 @@ import { SEVERITY_TEXT, BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../uti
5
5
 
6
6
  export interface AuditFinding {
7
7
  kind: string
8
+ /** API group, backfilled by the backend from the builtin Kind→group table
9
+ * (built-ins → e.g. "apps"/"batch"; CRDs → ""). Part of the resource key
10
+ * used to join findings onto topology nodes / list rows. */
11
+ group?: string
8
12
  namespace: string
9
13
  name: string
10
14
  checkID: string
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { AuditBadgeTooltip } from './AuditBadgeTooltip'
4
+
5
+ const msgs = [
6
+ { severity: 'warning', message: 'Service selector matches no pods' },
7
+ { severity: 'danger', message: 'Ingress references missing Service' },
8
+ { severity: 'warning', message: 'Uses a deprecated API version' },
9
+ { severity: 'warning', message: 'A fourth finding' },
10
+ ]
11
+
12
+ describe('AuditBadgeTooltip', () => {
13
+ it('lists each finding message up to the cap', () => {
14
+ const html = renderToString(<AuditBadgeTooltip messages={msgs.slice(0, 2)} />)
15
+ expect(html).toContain('Service selector matches no pods')
16
+ expect(html).toContain('Ingress references missing Service')
17
+ expect(html).not.toContain('more')
18
+ })
19
+
20
+ it('caps the list and collapses the rest into "+N more"', () => {
21
+ const html = renderToString(<AuditBadgeTooltip messages={msgs} max={3} />)
22
+ expect(html).toContain('+1 more')
23
+ expect(html).not.toContain('A fourth finding')
24
+ })
25
+
26
+ it('shows the click hint by default and omits it when disabled', () => {
27
+ expect(renderToString(<AuditBadgeTooltip messages={msgs.slice(0, 1)} />)).toContain('Click to open')
28
+ expect(renderToString(<AuditBadgeTooltip messages={msgs.slice(0, 1)} clickHint={false} />)).not.toContain('Click to open')
29
+ })
30
+ })
@@ -0,0 +1,47 @@
1
+ import { ShieldAlert, AlertTriangle } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { SEVERITY_TEXT } from '../../utils/badge-colors'
4
+
5
+ export interface AuditBadgeMessage {
6
+ severity: string
7
+ message: string
8
+ }
9
+
10
+ interface AuditBadgeTooltipProps {
11
+ messages: AuditBadgeMessage[]
12
+ /** Max messages to list before collapsing the rest into "+N more". */
13
+ max?: number
14
+ /** Hint that clicking opens the resource — omitted when the badge isn't clickable. */
15
+ clickHint?: boolean
16
+ }
17
+
18
+ /**
19
+ * Inline tooltip body for an audit badge: lists the actual finding messages
20
+ * (danger-first) so the operator reads WHAT is wrong on hover, instead of a
21
+ * content-free "N findings". Shared by the resource-list and topology-node
22
+ * badges so the two can't drift.
23
+ */
24
+ export function AuditBadgeTooltip({ messages, max = 3, clickHint = true }: AuditBadgeTooltipProps) {
25
+ const shown = messages.slice(0, max)
26
+ const overflow = messages.length - shown.length
27
+ return (
28
+ <div className="flex flex-col gap-1 text-left">
29
+ {shown.map((m, i) => {
30
+ const isDanger = m.severity === 'danger'
31
+ const Icon = isDanger ? ShieldAlert : AlertTriangle
32
+ return (
33
+ <div key={i} className="flex items-start gap-1.5">
34
+ <Icon className={clsx('w-3 h-3 shrink-0 mt-0.5', isDanger ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)} />
35
+ <span>{m.message}</span>
36
+ </div>
37
+ )
38
+ })}
39
+ {overflow > 0 && (
40
+ <div className="text-theme-text-tertiary">{`+${overflow} more`}</div>
41
+ )}
42
+ {clickHint && (
43
+ <div className="text-theme-text-tertiary mt-0.5">Click to open →</div>
44
+ )}
45
+ </div>
46
+ )
47
+ }
@@ -27,6 +27,10 @@ export interface CheckMeta {
27
27
  remediation: string
28
28
  frameworks?: string[]
29
29
  references?: CheckReference[]
30
+ /** Finding means the specific resource is broken (reference-integrity /
31
+ * lifecycle), worth a per-resource topology/list badge — vs posture /
32
+ * best-practice checks that fire near-universally. Set by the backend registry. */
33
+ badgeWorthy?: boolean
30
34
  }
31
35
 
32
36
  /** An authoritative link for a check (K8s docs, CIS, NSA/CISA, …). */
@@ -1,3 +1,4 @@
1
1
  export { AuditCard, type AuditCardData } from './AuditCard'
2
2
  export { AuditAlerts, type AuditFinding } from './AuditAlerts'
3
+ export { AuditBadgeTooltip, type AuditBadgeMessage } from './AuditBadgeTooltip'
3
4
  export { AuditFindingsTable, type AuditFindingsTableProps, type ResourceGroup, type CheckMeta, type CheckReference } from './AuditFindingsTable'
@@ -58,6 +58,15 @@ export interface ClusterSwitcherProps {
58
58
  errorSlot?: ReactNode
59
59
  className?: string
60
60
  align?: 'left' | 'right'
61
+ /**
62
+ * 'chip' (default) renders a self-contained bordered pill. 'segment' renders
63
+ * a borderless label+value cell for embedding in a shared bordered container
64
+ * (the unified cluster+namespace scope control) — no border/background/min-w
65
+ * of its own, with an optional muted {@link label} before the value.
66
+ */
67
+ variant?: 'chip' | 'segment'
68
+ /** Muted label shown before the value in the 'segment' variant (e.g. "Cluster"). */
69
+ label?: string
61
70
  }
62
71
 
63
72
  // Trigger width cap. With middle-truncation kicking in, this is a
@@ -83,6 +92,8 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
83
92
  errorSlot,
84
93
  className = '',
85
94
  align = 'left',
95
+ variant = 'chip',
96
+ label,
86
97
  }, ref) => {
87
98
  const [isOpen, setIsOpen] = useState(false)
88
99
  const [search, setSearch] = useState('')
@@ -195,14 +206,21 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
195
206
  type="button"
196
207
  onClick={() => setIsOpen(v => !v)}
197
208
  disabled={disabled || loading}
198
- className={`
199
- flex items-center gap-1.5 px-2.5 py-1.5 min-w-[140px]
200
- bg-theme-elevated border border-theme-border rounded text-sm font-medium
201
- text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
202
- transition-colors cursor-pointer
203
- disabled:opacity-50 disabled:cursor-not-allowed
204
- `}
209
+ className={
210
+ variant === 'segment'
211
+ ? `flex items-center gap-1.5 px-3 py-1.5 h-full min-w-[150px] max-w-[264px] text-[13px] font-medium
212
+ text-theme-text-primary hover:bg-theme-hover transition-colors cursor-pointer
213
+ disabled:opacity-50 disabled:cursor-not-allowed`
214
+ : `flex items-center gap-1.5 px-2.5 py-1.5 min-w-[140px]
215
+ bg-theme-elevated border border-theme-border rounded text-sm font-medium
216
+ text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
217
+ transition-colors cursor-pointer
218
+ disabled:opacity-50 disabled:cursor-not-allowed`
219
+ }
205
220
  >
221
+ {label && (
222
+ <span className="shrink-0 font-normal text-theme-text-tertiary">{label}</span>
223
+ )}
206
224
  {loading ? (
207
225
  <>
208
226
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -220,7 +238,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
220
238
  <ClusterName
221
239
  name={currentName}
222
240
  fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
223
- className={TRIGGER_NAME_MAX_WIDTH}
241
+ className={variant === 'segment' ? 'min-w-0 max-w-[214px]' : TRIGGER_NAME_MAX_WIDTH}
224
242
  noTooltip={isOpen}
225
243
  />
226
244
  {currentSourceLabel && (
@@ -240,7 +258,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
240
258
  )}
241
259
  </>
242
260
  )}
243
- <ChevronDown className={`w-3 h-3 ml-auto transition-transform ${isOpen ? 'rotate-180' : ''}`} />
261
+ <ChevronDown className={`w-3 h-3 shrink-0 transition-transform ${variant === 'segment' ? '' : 'ml-auto'} ${isOpen ? 'rotate-180' : ''}`} />
244
262
  </button>
245
263
 
246
264
  {isOpen && (
@@ -184,11 +184,11 @@ export interface GitOpsDetailLayoutProps {
184
184
  isFleetContext?: boolean
185
185
  destinationCluster?: { id: string; name: string }
186
186
 
187
- // Tab-title side effect — sets document.title to "<name> Radar" while
187
+ // Tab-title side effect — sets document.title to "<name> · Radar" while
188
188
  // mounted, restores on unmount. Opt-in so hub-web fleet detail can pick
189
189
  // its own title format ("<name> — Fleet GitOps").
190
190
  manageDocumentTitle?: boolean
191
- documentTitleSuffix?: string // defaults to " Radar"
191
+ documentTitleSuffix?: string // defaults to " · Radar"
192
192
 
193
193
  // Children slot — for dialogs (SyncOptionsDialog, RollbackDialog, …)
194
194
  // that should portal to body. Caller owns dialog state. Children render
@@ -246,7 +246,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
246
246
  useEffect(() => {
247
247
  if (!manageDocumentTitle) return
248
248
  const previous = document.title
249
- const suffix = documentTitleSuffix ?? ' Radar'
249
+ const suffix = documentTitleSuffix ?? ' · Radar'
250
250
  document.title = `${identity.name}${suffix}`
251
251
  return () => { document.title = previous }
252
252
  }, [identity.name, manageDocumentTitle, documentTitleSuffix])
@@ -67,7 +67,10 @@ function getStatusIcon(status: GitOpsStatus) {
67
67
  }
68
68
 
69
69
  function getStatusColorClass(status: GitOpsStatus): string {
70
- if (status.suspended) return SEVERITY_BADGE_BORDERED.warning
70
+ // Suspended sync (manual-sync mode) is an intentional operating mode many teams
71
+ // run by default — sky (info), not amber. Real drift surfaces separately as
72
+ // OutOfSync (still amber); the Pause icon + "Suspended" label signal it's manual.
73
+ if (status.suspended) return SEVERITY_BADGE_BORDERED.info
71
74
  if (status.sync === 'Synced' && status.health === 'Healthy') return SEVERITY_BADGE_BORDERED.success
72
75
  if (status.health === 'Degraded') return SEVERITY_BADGE_BORDERED.error
73
76
  if (status.sync === 'OutOfSync') return SEVERITY_BADGE_BORDERED.warning
@@ -112,7 +115,9 @@ function getHealthInfo(health: GitOpsHealthStatus) {
112
115
  case 'Degraded':
113
116
  return { icon: XCircle, color: SEVERITY_BADGE.error, label: 'Degraded' }
114
117
  case 'Suspended':
115
- return { icon: Pause, color: SEVERITY_BADGE.warning, label: 'Suspended' }
118
+ // Intentional pause, not a degradation — sky (info), matching the resource
119
+ // table + app rollup. The Pause icon already signals it's deliberate.
120
+ return { icon: Pause, color: SEVERITY_BADGE.info, label: 'Suspended' }
116
121
  case 'Missing':
117
122
  return { icon: AlertCircle, color: SEVERITY_BADGE.warning, label: 'Missing' }
118
123
  default:
@@ -125,8 +130,9 @@ function getHealthInfo(health: GitOpsHealthStatus) {
125
130
  */
126
131
  export function SyncStatusBadge({ sync, suspended }: { sync: SyncStatus; suspended?: boolean }) {
127
132
  if (suspended) {
133
+ // Manual-sync mode is intentional — sky (info), not amber. See getStatusColorClass.
128
134
  return (
129
- <span className={clsx('badge', SEVERITY_BADGE_BORDERED.warning)}>
135
+ <span className={clsx('badge', SEVERITY_BADGE_BORDERED.info)}>
130
136
  <Pause className="w-3 h-3" />
131
137
  Suspended
132
138
  </span>
@@ -33,7 +33,6 @@ import { SortableTh, TH_CLASS, type SortDir } from '../ui/SortableTh'
33
33
  import { DistributionBar } from '../ui/DistributionBar'
34
34
  import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
35
35
  import { PaneLoader } from '../ui/PaneLoader'
36
- import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
37
36
  import { getGitOpsResourceStatus } from './detail-helpers'
38
37
  import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
39
38
  import { toggleSet } from './GitOpsGraphFilterRail'
@@ -192,8 +191,13 @@ export interface GitOpsTableViewProps {
192
191
  // the Scope-section mode tabs and the empty-state check.
193
192
  counts: Record<string, number>
194
193
  countsUnavailable?: string[]
195
- // Caller refresh typically invalidates its useQuery + refetches.
194
+ // @deprecated Superseded by `freshnessSlot` kept for source compatibility
195
+ // with existing consumers; no longer drives any affordance here.
196
196
  onRefresh?: () => void
197
+ // Host-injected freshness/liveness control (e.g. a <FreshnessControl>),
198
+ // rendered leading the header actions. The host owns the mode + data, so this
199
+ // shared table makes no assumption about whether the view auto-updates.
200
+ freshnessSlot?: ReactNode
197
201
  // Row click — caller routes to its own detail page. When the host also
198
202
  // passes `rowHrefFor`, the callback receives the MouseEvent so it can
199
203
  // `preventDefault()` for same-tree nav (e.g. react-router) or skip the
@@ -275,7 +279,7 @@ export function GitOpsTableView({
275
279
  error,
276
280
  counts,
277
281
  countsUnavailable,
278
- onRefresh,
282
+ freshnessSlot,
279
283
  onRowClick,
280
284
  rowHrefFor,
281
285
  onDestinationClick,
@@ -317,15 +321,19 @@ export function GitOpsTableView({
317
321
  })
318
322
  }, [])
319
323
  const [lifecycleFilter, setLifecycleFilter] = useState<'all' | 'terminating' | 'active'>('all')
320
- const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: 'urgency', dir: 'asc' })
321
- // Shared refresh feedback (spin ≥400mscheckmark) so clicking Refresh gives
322
- // the same visual confirmation as every other view, even when the refetch is
323
- // instant (the cache is already warm).
324
- const [triggerRefresh, , refreshPhase] = useRefreshAnimation(onRefresh ?? (() => {}))
325
- // Clicking a column sorts by it (starting at the column's natural direction —
326
- // e.g. last-sync newest-first); clicking the active column reverses.
324
+ const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>({ key: 'urgency', dir: 'asc' })
325
+ // 3-state cycle: natural direction reversed off. The first click uses each
326
+ // column's natural direction (SORT_DEFAULT_DIR e.g. Last Sync is newest-first)
327
+ // so the header cycle agrees with the tile-mode sort menu, which seeds the same
328
+ // default. "Off" (null) falls back to the urgency/health-worst-first ordering.
327
329
  const onSort = useCallback(
328
- (key: SortKey) => setSort((prev) => (prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: SORT_DEFAULT_DIR[key] })),
330
+ (key: SortKey) =>
331
+ setSort((prev) => {
332
+ const natural = SORT_DEFAULT_DIR[key]
333
+ if (!prev || prev.key !== key) return { key, dir: natural }
334
+ if (prev.dir === natural) return { key, dir: natural === 'asc' ? 'desc' : 'asc' }
335
+ return null
336
+ }),
329
337
  [],
330
338
  )
331
339
 
@@ -453,7 +461,8 @@ export function GitOpsTableView({
453
461
  }
454
462
  return true
455
463
  })
456
- return [...rows].sort((a, b) => compareRows(a, b, sort.key) * (sort.dir === 'asc' ? 1 : -1))
464
+ const eff = sort ?? { key: 'urgency' as SortKey, dir: 'asc' as SortDir }
465
+ return [...rows].sort((a, b) => compareRows(a, b, eff.key) * (eff.dir === 'asc' ? 1 : -1))
457
466
  }, [allRows, automationFilters, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sort, syncFilters, destinationFilter])
458
467
 
459
468
  const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
@@ -596,19 +605,24 @@ export function GitOpsTableView({
596
605
  icon={GitBranch}
597
606
  title="GitOps"
598
607
  description="Applications and reconciliations with source, destination, sync, and health state."
599
- actions={summaryTiles.map((tile) => (
600
- <SummaryTile
601
- key={tile.key}
602
- label={tile.label}
603
- value={tile.value}
604
- tone={tile.tone}
605
- active={tile.active}
606
- onClick={() => {
607
- if (tile.active) tile.clear?.()
608
- else tile.apply?.()
609
- }}
610
- />
611
- ))}
608
+ actions={
609
+ <>
610
+ {freshnessSlot}
611
+ {summaryTiles.map((tile) => (
612
+ <SummaryTile
613
+ key={tile.key}
614
+ label={tile.label}
615
+ value={tile.value}
616
+ tone={tile.tone}
617
+ active={tile.active}
618
+ onClick={() => {
619
+ if (tile.active) tile.clear?.()
620
+ else tile.apply?.()
621
+ }}
622
+ />
623
+ ))}
624
+ </>
625
+ }
612
626
  />
613
627
  </div>
614
628
  <div
@@ -678,7 +692,7 @@ export function GitOpsTableView({
678
692
  pattern); tile mode has no headers, so it keeps a compact sort
679
693
  control wired to the same sort state. */}
680
694
  {viewMode === 'tiles' && (
681
- <GitOpsSortMenu sortKey={sort.key} onChange={(k) => setSort({ key: k, dir: SORT_DEFAULT_DIR[k] })} />
695
+ <GitOpsSortMenu sortKey={sort?.key ?? 'urgency'} onChange={(k) => setSort({ key: k, dir: SORT_DEFAULT_DIR[k] })} />
682
696
  )}
683
697
  {labels.length > 0 && (
684
698
  <LabelsDropdown
@@ -727,19 +741,6 @@ export function GitOpsTableView({
727
741
  <GitOpsIconToggle active={viewMode === 'table'} label="Table view" icon={List} onClick={() => setViewMode('table')} />
728
742
  <GitOpsIconToggle active={viewMode === 'tiles'} label="Tiles view" icon={LayoutGrid} onClick={() => setViewMode('tiles')} />
729
743
  </div>
730
- {onRefresh && (
731
- <Tooltip content="Refresh GitOps resources">
732
- <button
733
- type="button"
734
- onClick={triggerRefresh}
735
- className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-theme-border bg-theme-base text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
736
- >
737
- {refreshPhase === 'success'
738
- ? <Check className="h-3.5 w-3.5 text-emerald-500" />
739
- : <RefreshCw className={clsx('h-3.5 w-3.5', (refreshPhase === 'spinning' || loading) && 'animate-spin')} />}
740
- </button>
741
- </Tooltip>
742
- )}
743
744
  </div>
744
745
  </div>
745
746
 
@@ -1187,7 +1188,7 @@ function GitOpsTable({
1187
1188
  pendingRowActions,
1188
1189
  }: {
1189
1190
  rows: GitOpsRow[]
1190
- sort: { key: SortKey; dir: SortDir }
1191
+ sort: { key: SortKey; dir: SortDir } | null
1191
1192
  onSort: (key: SortKey) => void
1192
1193
  onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
1193
1194
  hrefFor?: (row: GitOpsRow) => string
@@ -1202,13 +1203,13 @@ function GitOpsTable({
1202
1203
  <table className="w-full min-w-[1040px] table-fixed border-separate border-spacing-0 text-sm">
1203
1204
  <thead className="sticky top-0 z-10 bg-theme-base">
1204
1205
  <tr>
1205
- <SortableTh label="Application" sortKey="name" activeKey={sort.key} direction={sort.dir} onSort={onSort} className={showActions ? 'w-[16%]' : 'w-[22%]'} />
1206
- <SortableTh label="Project" sortKey="project" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1207
- <SortableTh label="Sync" sortKey="sync" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1208
- <SortableTh label="Health" sortKey="health" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1206
+ <SortableTh label="Application" sortKey="name" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className={showActions ? 'w-[16%]' : 'w-[22%]'} />
1207
+ <SortableTh label="Project" sortKey="project" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
1208
+ <SortableTh label="Sync" sortKey="sync" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
1209
+ <SortableTh label="Health" sortKey="health" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
1209
1210
  <th className={clsx(TH_CLASS, showDestination ? 'w-[20%]' : 'w-[28%]')}>Source</th>
1210
1211
  {showDestination && <th className={clsx(TH_CLASS, 'w-[14%]')}>Destination</th>}
1211
- <SortableTh label="Last Sync" sortKey="lastSync" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[10%]" />
1212
+ <SortableTh label="Last Sync" sortKey="lastSync" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[10%]" />
1212
1213
  {showActions && (
1213
1214
  <th className={clsx(TH_CLASS, 'w-[6%] text-right')}>
1214
1215
  <span className="sr-only">Actions</span>
@@ -1704,7 +1705,9 @@ function compareRows(a: GitOpsRow, b: GitOpsRow, sortKey: SortKey) {
1704
1705
  // row sorting above a Synced-Progressing one. A sync-aware triage ordering is a
1705
1706
  // reasonable separate default, but not what "sort by Health" should mean.)
1706
1707
  const HEALTH_RANK: Record<string, number> = {
1707
- Degraded: 0, Missing: 0, Suspended: 1, Unknown: 2, Progressing: 3, Healthy: 4,
1708
+ // Suspended is intentional/benign (neutral), so it sorts at the healthy end
1709
+ // not near Degraded/Missing — matching its sky tone across the other surfaces.
1710
+ Degraded: 0, Missing: 0, Unknown: 2, Progressing: 3, Healthy: 4, Suspended: 4,
1708
1711
  }
1709
1712
  function healthRank(row: GitOpsRow): number {
1710
1713
  return HEALTH_RANK[row.health] ?? 2
@@ -56,6 +56,7 @@ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps)
56
56
  const operationFailure = (insight.issues ?? []).find(
57
57
  (i) => i.severity === 'critical' && i.scope === 'operation' && i.stuck,
58
58
  )
59
+ const operationTooltipMessage = summary.rawOperationMessage || summary.operationMessage
59
60
  return (
60
61
  <div className="border-b border-theme-border bg-theme-base px-4 py-2">
61
62
  <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
@@ -87,7 +88,7 @@ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps)
87
88
  (parsed cause, retry count, raw message) so the strip stays a
88
89
  calm orientation row instead of duplicating the error three times. */}
89
90
  {operation && summary.operationMessage && isInFlightPhase(operation) && (
90
- <Tooltip content={summary.operationMessage} delay={400} wrapperClassName="min-w-0 max-w-[60ch]">
91
+ <Tooltip content={operationTooltipMessage} delay={400} wrapperClassName="min-w-0 max-w-[60ch]">
91
92
  <span className="block truncate text-[11px] text-theme-text-secondary">
92
93
  {summary.operationMessage}
93
94
  </span>
@@ -424,6 +425,8 @@ function GitOpsFailureCard({
424
425
  const [showRaw, setShowRaw] = useState(false)
425
426
  const stuck = !!issue.stuck
426
427
  const ref = issue.refs?.[0]
428
+ const rawControllerMessage = issue.rawMessage || issue.message
429
+ const rawControllerLabel = issue.rawMessage ? 'raw controller error' : 'controller message'
427
430
  // Title prioritizes the parsed cause's first sentence. Without parsing we
428
431
  // get the bare phase ("Failed") which alone tells the user nothing — fall
429
432
  // back to the first sentence of the raw message in that case so something
@@ -486,12 +489,12 @@ function GitOpsFailureCard({
486
489
  className="inline-flex items-center gap-1 text-[11px] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
487
490
  >
488
491
  {showRaw ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
489
- {showRaw ? 'Hide raw controller error' : 'Show raw controller error'}
492
+ {showRaw ? `Hide ${rawControllerLabel}` : `Show ${rawControllerLabel}`}
490
493
  </button>
491
494
  </div>
492
495
  {showRaw && (
493
496
  <pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border border-theme-border bg-theme-base px-3 py-2 font-mono text-[11px] text-theme-text-secondary">
494
- {issue.message}
497
+ {rawControllerMessage}
495
498
  </pre>
496
499
  )}
497
500
  </div>
@@ -609,6 +612,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
609
612
  const t = severityTone(issue.severity)
610
613
  const ref = issue.refs?.[0]
611
614
  const actionable = !!(onSelectIssue && ref)
615
+ const rawMessage = issue.rawMessage && issue.rawMessage !== issue.message ? issue.rawMessage : ''
612
616
  return (
613
617
  <button
614
618
  key={`${issue.reason}-${index}`}
@@ -628,6 +632,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
628
632
  </div>
629
633
  <p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
630
634
  {issue.cause && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.cause}</p>}
635
+ {rawMessage && <p className="mt-0.5 break-words font-mono text-[11px] text-theme-text-tertiary">{rawMessage}</p>}
631
636
  {issue.action && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
632
637
  </div>
633
638
  {actionable && ref && (
@@ -1112,7 +1117,7 @@ function ChangeRow({
1112
1117
  live health message — operators chasing a broken sync want
1113
1118
  the failure reason on the same row, not in a drawer. */}
1114
1119
  {change.syncError && (
1115
- <Tooltip content={change.syncError} delay={400} wrapperClassName="ml-[18px] mt-1 block max-w-full">
1120
+ <Tooltip content={change.rawSyncError || change.syncError} delay={400} wrapperClassName="ml-[18px] mt-1 block max-w-full">
1116
1121
  <span className="line-clamp-3 text-xs text-red-600 dark:text-red-400">{change.syncError}</span>
1117
1122
  </Tooltip>
1118
1123
  )}
@@ -1386,7 +1391,9 @@ function HistoryRows({
1386
1391
  </Tooltip>
1387
1392
  )}
1388
1393
  {item.message && (
1389
- <div className={clsx('mt-0.5 line-clamp-2 text-[11px]', sourceDisplay ? 'text-theme-text-tertiary' : 'text-theme-text-secondary')}>{item.message}</div>
1394
+ <Tooltip content={item.rawMessage || item.message} delay={400} wrapperClassName="mt-0.5 block max-w-full">
1395
+ <div className={clsx('line-clamp-2 text-[11px]', sourceDisplay ? 'text-theme-text-tertiary' : 'text-theme-text-secondary')}>{item.message}</div>
1396
+ </Tooltip>
1390
1397
  )}
1391
1398
  </div>
1392
1399
  </li>