@skyhook-io/k8s-ui 1.17.1 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +13 -0
  2. package/package.json +4 -4
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
  7. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  8. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  9. package/src/components/gitops/detail-helpers.test.ts +58 -13
  10. package/src/components/gitops/detail-helpers.ts +37 -6
  11. package/src/components/gitops/index.ts +1 -0
  12. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  14. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  15. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  16. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  17. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  18. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  19. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  20. package/src/components/issues/IssuesView.tsx +18 -10
  21. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  22. package/src/components/issues/diagnostic.ts +2 -0
  23. package/src/components/issues/index.ts +1 -1
  24. package/src/components/issues/issues.test.ts +26 -0
  25. package/src/components/issues/severity.ts +9 -2
  26. package/src/components/issues/types.ts +32 -4
  27. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  28. package/src/components/logs/LogCore.tsx +159 -40
  29. package/src/components/logs/LogsViewer.test.tsx +183 -60
  30. package/src/components/logs/LogsViewer.tsx +49 -12
  31. package/src/components/logs/StructuredLogLine.tsx +1 -1
  32. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  33. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  34. package/src/components/logs/useLogBuffer.ts +48 -18
  35. package/src/components/logs/useLogSearch.ts +3 -1
  36. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  37. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  38. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  39. package/src/components/resources/ColumnPickerList.tsx +174 -0
  40. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  41. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  42. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  43. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  44. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  45. package/src/components/resources/ResourcesView.tsx +198 -93
  46. package/src/components/resources/column-order.test.ts +104 -0
  47. package/src/components/resources/column-order.ts +64 -0
  48. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  49. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  50. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  51. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  52. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  53. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  54. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  55. package/src/components/resources/renderers/PodRenderer.tsx +72 -20
  56. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  57. package/src/components/resources/resource-utils-trivy.ts +122 -0
  58. package/src/components/resources/resource-utils.ts +15 -2
  59. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  60. package/src/components/shared/DetailShell.tsx +9 -7
  61. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  62. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  63. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  64. package/src/components/shared/ManagedByChip.tsx +24 -4
  65. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  66. package/src/components/shared/ResourceActionsBar.tsx +12 -6
  67. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  68. package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
  69. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  70. package/src/components/timeline/DiffViewer.tsx +113 -4
  71. package/src/components/timeline/TimelineList.tsx +5 -1
  72. package/src/components/timeline/shared.test.tsx +48 -0
  73. package/src/components/timeline/shared.tsx +14 -19
  74. package/src/components/ui/FetchResult.test.tsx +19 -2
  75. package/src/components/ui/FetchResult.tsx +13 -1
  76. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  77. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  78. package/src/components/ui/PageHeader.tsx +6 -4
  79. package/src/components/ui/Toast.tsx +48 -30
  80. package/src/components/ui/drawer-components.test.tsx +253 -0
  81. package/src/components/ui/drawer-components.tsx +315 -11
  82. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  83. package/src/components/ui/index.ts +1 -1
  84. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  85. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  86. package/src/components/workload/WorkloadView.tsx +68 -14
  87. package/src/hooks/index.ts +2 -0
  88. package/src/hooks/useMediaQuery.ts +21 -0
  89. package/src/hooks/usePhoneLayout.ts +12 -0
  90. package/src/theme/tailwind-theme.css +5 -0
  91. package/src/types/core.ts +91 -3
  92. package/src/types/gitops-flux-inventory.test.ts +24 -0
  93. package/src/types/gitops-insights.ts +1 -0
  94. package/src/types/gitops-tree.ts +2 -0
  95. package/src/types/gitops.ts +5 -5
  96. package/src/types/index.ts +3 -0
  97. package/src/types/read-coverage.ts +65 -0
  98. package/src/types/resource-coverage.ts +40 -0
  99. package/src/utils/format.test.ts +7 -0
  100. package/src/utils/format.ts +6 -1
  101. package/src/utils/gitops-owner.test.ts +42 -1
  102. package/src/utils/gitops-owner.ts +22 -0
  103. package/src/utils/index.ts +2 -0
  104. package/src/utils/read-actions.test.ts +22 -0
  105. package/src/utils/read-actions.ts +56 -0
  106. package/src/utils/resource-coverage.test.ts +121 -0
  107. package/src/utils/resource-coverage.ts +117 -0
@@ -1,6 +1,6 @@
1
1
  import { memo } from 'react'
2
2
  import { clsx } from 'clsx'
3
- import { ArrowRight, Plus, Minus, RefreshCw } from 'lucide-react'
3
+ import { ArrowRight, EyeOff, Plus, Minus, RefreshCw } from 'lucide-react'
4
4
  import type { DiffInfo, FieldChange } from '../../types'
5
5
 
6
6
  interface DiffViewerProps {
@@ -34,19 +34,128 @@ export const DiffViewer = memo(function DiffViewer({ diff, compact = false }: Di
34
34
 
35
35
  {/* Field changes */}
36
36
  <div className="space-y-1.5">
37
- {diff.fields.map((field, idx) => (
38
- <FieldChangeRow key={`${field.path}-${idx}`} field={field} />
39
- ))}
37
+ {diffRows(diff.fields).map((row, idx) =>
38
+ row.kind === 'keys' ? (
39
+ <KeyListRow key={`${row.field}-${idx}`} row={row} />
40
+ ) : (
41
+ <FieldChangeRow key={`${row.field.path}-${idx}`} field={row.field} />
42
+ ),
43
+ )}
40
44
  </div>
41
45
  </div>
42
46
  )
43
47
  })
44
48
 
49
+ // Secret and ConfigMap diffs report which keys changed as name lists, under
50
+ // paths like "data (modified keys)", never values. Rendered as an old→new
51
+ // pair, a modified key reads as removed and re-added, so key lists of one
52
+ // field are merged into a single row that names what happened to each key.
53
+ const KEY_LIST_PATH = /^(.+) \((added|removed|modified) keys\)$/
54
+
55
+ type KeyChange = 'added' | 'removed' | 'modified'
56
+
57
+ interface KeyListDiffRow {
58
+ kind: 'keys'
59
+ field: string
60
+ keys: Record<KeyChange, string[]>
61
+ }
62
+
63
+ type DiffRow = KeyListDiffRow | { kind: 'field'; field: FieldChange }
64
+
65
+ function keyNames(value: unknown): string[] | null {
66
+ if (value === null || value === undefined) return []
67
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : null
68
+ }
69
+
70
+ function diffRows(fields: FieldChange[]): DiffRow[] {
71
+ const rows: DiffRow[] = []
72
+ const byField = new Map<string, KeyListDiffRow>()
73
+ for (const field of fields) {
74
+ const match = KEY_LIST_PATH.exec(field.path)
75
+ const oldKeys = keyNames(field.oldValue)
76
+ const newKeys = keyNames(field.newValue)
77
+ if (!match || !oldKeys || !newKeys) {
78
+ rows.push({ kind: 'field', field })
79
+ continue
80
+ }
81
+ const [, base, change] = match
82
+ let row = byField.get(base)
83
+ if (!row) {
84
+ row = { kind: 'keys', field: base, keys: { added: [], removed: [], modified: [] } }
85
+ byField.set(base, row)
86
+ rows.push(row)
87
+ }
88
+ const list = row.keys[change as KeyChange]
89
+ for (const key of [...oldKeys, ...newKeys]) {
90
+ if (!list.includes(key)) list.push(key)
91
+ }
92
+ }
93
+ return rows
94
+ }
95
+
96
+ const KEY_CHANGE_LABEL: Record<KeyChange, string> = {
97
+ modified: 'keys changed',
98
+ added: 'added',
99
+ removed: 'removed',
100
+ }
101
+
102
+ function KeyListRow({ row }: { row: KeyListDiffRow }) {
103
+ const parts = (['modified', 'added', 'removed'] as const).filter((change) => row.keys[change].length > 0)
104
+ return (
105
+ <div className="rounded bg-theme-surface/50 border border-theme-border px-3 py-2">
106
+ <div className="text-xs font-mono text-theme-text-tertiary mb-1 break-words [overflow-wrap:anywhere]">{row.field}</div>
107
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm text-theme-text-secondary">
108
+ {parts.map((change, idx) => (
109
+ <span key={change} className="min-w-0 [overflow-wrap:anywhere]">
110
+ {idx > 0 ? <span aria-hidden className="mr-2 text-theme-text-tertiary">·</span> : null}
111
+ {KEY_CHANGE_LABEL[change]}: <span className="font-mono">{row.keys[change].join(', ')}</span>
112
+ </span>
113
+ ))}
114
+ </div>
115
+ </div>
116
+ )
117
+ }
118
+
45
119
  interface FieldChangeRowProps {
46
120
  field: FieldChange
47
121
  }
48
122
 
123
+ // The backend never sends values it keeps out of the feed (secrets, config
124
+ // blobs, large structures); it sends one of these markers instead. Older
125
+ // events carry a bare "changed" on both sides.
126
+ const HIDDEN_VALUE = '(value hidden)'
127
+ const HIDDEN_SET = '(set)'
128
+
129
+ function hiddenChange(field: FieldChange): 'changed' | 'set' | 'removed' | null {
130
+ const { oldValue, newValue } = field
131
+ const absent = (v: unknown) => v === null || v === undefined
132
+ if ((oldValue === HIDDEN_VALUE && newValue === HIDDEN_VALUE) || (oldValue === 'changed' && newValue === 'changed')) {
133
+ return 'changed'
134
+ }
135
+ if (absent(oldValue) && newValue === HIDDEN_SET) return 'set'
136
+ if (oldValue === HIDDEN_SET && absent(newValue)) return 'removed'
137
+ return null
138
+ }
139
+
49
140
  function FieldChangeRow({ field }: FieldChangeRowProps) {
141
+ const hidden = hiddenChange(field)
142
+ if (hidden) {
143
+ const Icon = hidden === 'set' ? Plus : hidden === 'removed' ? Minus : RefreshCw
144
+ return (
145
+ <div className="rounded bg-theme-surface/50 border border-theme-border px-3 py-2">
146
+ <div className="text-xs font-mono text-theme-text-tertiary mb-1 break-words [overflow-wrap:anywhere]">{field.path}</div>
147
+ <div className="flex flex-wrap items-center gap-2 text-sm text-theme-text-secondary">
148
+ <Icon className="w-3.5 h-3.5 shrink-0" aria-hidden />
149
+ <span>{hidden === 'set' ? 'Set' : hidden === 'removed' ? 'Removed' : 'Changed'}</span>
150
+ <span className="inline-flex items-center gap-1 rounded-full bg-theme-elevated px-2 py-0.5 text-xs text-theme-text-tertiary">
151
+ <EyeOff className="w-3 h-3" aria-hidden />
152
+ value hidden
153
+ </span>
154
+ </div>
155
+ </div>
156
+ )
157
+ }
158
+
50
159
  const isAdded = field.oldValue === null || field.oldValue === undefined
51
160
  const isRemoved = field.newValue === null || field.newValue === undefined
52
161
  const isModified = !isAdded && !isRemoved
@@ -216,6 +216,10 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
216
216
  const singleEvents: TimelineEvent[] = []
217
217
 
218
218
  for (const item of items) {
219
+ if (item.id === selectedEventId) {
220
+ singleEvents.push(item)
221
+ continue
222
+ }
219
223
  if (foldPerResource?.(item)) {
220
224
  const key = `fold:${item.kind}:${item.namespace}:${item.name}`
221
225
  const existing = groups.get(key) || []
@@ -344,7 +348,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
344
348
  if (older.length > 0) groups.push({ label: 'Older', items: aggregateEvents(older) })
345
349
 
346
350
  return groups
347
- }, [filteredActivity, compact, foldPerResource])
351
+ }, [filteredActivity, compact, foldPerResource, selectedEventId])
348
352
 
349
353
  // Visible-window reporting: rows carry their time span in data attributes;
350
354
  // on scroll (rAF-throttled) and whenever the row set changes, the span of
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
+ isRolloutEvent,
3
4
  buildHealthSpans,
4
5
  sweepAggregateHealth,
5
6
  formatAggregateHealthTooltip,
@@ -217,3 +218,50 @@ describe('buildLaneMemberSpans (leaf gathering)', () => {
217
218
  expect(members.map((m) => m.name)).toEqual(['web'])
218
219
  })
219
220
  })
221
+
222
+ describe('isRolloutEvent', () => {
223
+ type F = { path: string; oldValue?: unknown; newValue?: unknown }
224
+ const ev = (kind: string, fields: F[], summary = '') => ({
225
+ id: 'e', source: 'informer', eventType: 'update', kind, namespace: 'shop', name: 'web',
226
+ timestamp: new Date(0).toISOString(),
227
+ diff: { summary, fields: fields.map((f) => ({ oldValue: 1, newValue: 2, ...f })) },
228
+ }) as any
229
+
230
+ it('does not treat a pod-template edit alone as a rollout (OnDelete and paused workloads do not roll)', () => {
231
+ expect(isRolloutEvent(ev('Deployment', [{ path: 'spec.template.metadata.annotations[kubectl.kubernetes.io/restartedAt]' }], 'pod template annotation kubectl.kubernetes.io/restartedAt added (new pod template revision)'))).toBe(false)
232
+ expect(isRolloutEvent(ev('DaemonSet', [{ path: 'spec.template.metadata.annotations[checksum/config]' }]))).toBe(false)
233
+ expect(isRolloutEvent(ev('Rollout', [{ path: 'spec.template.spec.containers[].image' }], 'image: web:2'))).toBe(false)
234
+ })
235
+
236
+ it('treats updated-count movement with steady readiness as a rollout', () => {
237
+ expect(isRolloutEvent(ev('Deployment', [{ path: 'status.updatedReplicas', oldValue: 1, newValue: 2 }]))).toBe(true)
238
+ expect(isRolloutEvent(ev('DaemonSet', [{ path: 'status.updatedNumberScheduled', oldValue: 1, newValue: 2 }]))).toBe(true)
239
+ })
240
+
241
+ it('keeps degraded health visible when readiness moves in the same update', () => {
242
+ // A pod swap, a lost pod and a scale-up under failing readiness are
243
+ // indistinguishable from the counts alone.
244
+ expect(isRolloutEvent(ev('DaemonSet', [
245
+ { path: 'status.numberReady', oldValue: 4, newValue: 3 },
246
+ { path: 'status.updatedNumberScheduled', oldValue: 1, newValue: 2 },
247
+ ]))).toBe(false)
248
+ expect(isRolloutEvent(ev('Deployment', [
249
+ { path: 'status.readyReplicas', oldValue: 3, newValue: 2 },
250
+ { path: 'status.updatedReplicas', oldValue: 3, newValue: 5 },
251
+ ]))).toBe(false)
252
+ })
253
+
254
+ it('treats the reset at the start of a rollout as a rollout', () => {
255
+ expect(isRolloutEvent(ev('Deployment', [{ path: 'status.updatedReplicas', oldValue: 3, newValue: 0 }], 'updated: 3→0'))).toBe(true)
256
+ })
257
+
258
+ it('does not treat a lost pod or unrelated changes as a rollout', () => {
259
+ expect(isRolloutEvent(ev('Deployment', [
260
+ { path: 'status.readyReplicas', oldValue: 3, newValue: 2 },
261
+ { path: 'status.updatedReplicas', oldValue: 3, newValue: 2 },
262
+ ]))).toBe(false)
263
+ expect(isRolloutEvent(ev('Deployment', [{ path: 'status.readyReplicas', oldValue: 3, newValue: 1 }]))).toBe(false)
264
+ expect(isRolloutEvent(ev('Deployment', [{ path: 'spec.strategy.rollingUpdate.maxUnavailable' }], 'updated: wording without the field'))).toBe(false)
265
+ expect(isRolloutEvent(ev('Service', [{ path: 'status.updatedReplicas', oldValue: 1, newValue: 2 }]))).toBe(false)
266
+ })
267
+ })
@@ -57,28 +57,23 @@ export interface HealthSpanResult {
57
57
  }
58
58
 
59
59
  /**
60
- * Check if an event indicates a rollout is in progress (expected degradation).
61
- * Rollout signals include:
62
- * - Diff summary contains "updated:" (indicating replica count or image changes)
63
- * - Diff summary mentions image changes
64
- * - Event is for a deployment-like workload with degraded health
60
+ * Check if an event shows a rollout in progress (expected degradation): a
61
+ * deployment-like workload whose updated-replica count moved while its ready
62
+ * count held. Decided from the diff's field paths, not from summary wording.
63
+ *
64
+ * The rule errs toward keeping degraded health visible. A pod-template edit
65
+ * alone does not count (OnDelete and paused workloads take it without
66
+ * rolling), and an update that also moves the ready count does not count
67
+ * either: a lost pod and a scale-up under failing readiness look the same as
68
+ * a pod swap from the counts alone.
65
69
  */
66
- function isRolloutEvent(event: TimelineEvent): boolean {
70
+ export function isRolloutEvent(event: TimelineEvent): boolean {
67
71
  // Only deployment-like workload controllers can have rollout transitions.
68
72
  if (!isDeploymentLikeWorkloadKind(event.kind)) return false
69
-
70
- // Check diff summary for rollout signals
71
- if (event.diff?.summary) {
72
- const summary = event.diff.summary.toLowerCase()
73
- // "updated:" typically indicates replica count changes during rollout
74
- if (summary.includes('updated:')) return true
75
- // Image changes trigger rollouts
76
- if (summary.includes('image(') || summary.includes('image:')) return true
77
- // Spec template changes often indicate rollout
78
- if (summary.includes('template')) return true
79
- }
80
-
81
- return false
73
+ const paths = new Set((event.diff?.fields ?? []).map((field) => field.path))
74
+ const updatedMoved = paths.has('status.updatedReplicas') || paths.has('status.updatedNumberScheduled')
75
+ const readyMoved = paths.has('status.readyReplicas') || paths.has('status.numberReady')
76
+ return updatedMoved && !readyMoved
82
77
  }
83
78
 
84
79
  /**
@@ -54,11 +54,12 @@ describe('FetchResult', () => {
54
54
  expect(html).toContain('Resource cache not available')
55
55
  })
56
56
 
57
- it('does not blame the cluster for a kind_sync_failed 503', () => {
57
+ // kind_sync_failed is how Radar releases before kind_sync_timed_out spelled it.
58
+ it.each(['kind_sync_timed_out', 'kind_sync_failed'])('does not blame the cluster for a %s 503', (code) => {
58
59
  const html = renderToString(
59
60
  <FetchResult
60
61
  loading={false}
61
- error={shaped('pods failed to load within the sync deadline', 503, { error_code: 'kind_sync_failed' })}
62
+ error={shaped('pods failed to load within the sync deadline', 503, { error_code: code })}
62
63
  />,
63
64
  )
64
65
  expect(html).not.toContain('Cluster unavailable')
@@ -66,6 +67,22 @@ describe('FetchResult', () => {
66
67
  expect(html).toContain('pods failed to load within the sync deadline')
67
68
  })
68
69
 
70
+ it('blames Radar\'s collector, not the cluster, for a collector failure', () => {
71
+ const html = renderToString(
72
+ <FetchResult loading={false} error={shaped('Radar is not allowed to list pods', 503, { error_code: 'radar_forbidden' })} />,
73
+ )
74
+ expect(html).toContain('Radar can')
75
+ expect(html).not.toContain('Cluster unavailable')
76
+ })
77
+
78
+ it('keeps a disconnected cluster reading as unavailable', () => {
79
+ const html = renderToString(
80
+ <FetchResult loading={false} error={shaped('Not connected to cluster', 503, { error_code: 'cluster_disconnected' })} />,
81
+ )
82
+ expect(html).toContain('Cluster unavailable')
83
+ expect(html).not.toContain('Radar can')
84
+ })
85
+
69
86
  it('renders a Retry button only when onRetry is given', () => {
70
87
  const err = shaped('internal server error', 500)
71
88
  expect(renderToString(<FetchResult loading={false} error={err} onRetry={() => {}} />)).toContain('Retry')
@@ -3,6 +3,7 @@ import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, RefreshCw, t
3
3
  import { clsx } from 'clsx'
4
4
  import { PaneLoader } from './PaneLoader'
5
5
  import { isFetchError } from '../../types/fetch-error'
6
+ import { readActionFor, canonicalReadCode } from '../../utils/read-actions'
6
7
 
7
8
  // FetchResult collapses (loading, error, no-data) into one rendered outcome:
8
9
  // loader while loading, a typed error surface when the fetch threw, or the
@@ -15,6 +16,7 @@ import { isFetchError } from '../../types/fetch-error'
15
16
  // error (403) → "Access denied" + error.message
16
17
  // error (404) → notFoundMessage + error.message
17
18
  // error (503 sync failed) → "Couldn't load this view" + error.message
19
+ // error (503 collector) → "Radar can't read this" + error.message
18
20
  // error (503) → "Cluster unavailable" + error.message
19
21
  // error (401) → "Sign-in required" (apiFetch redirects; fallback)
20
22
  // error (other / no shape) → "Couldn't load this view" + error.message
@@ -118,9 +120,19 @@ function classify(error: unknown, notFoundMessage: string): Classified {
118
120
  case 503:
119
121
  // The cluster is connected; one kind's informer missed its sync
120
122
  // deadline, so "Cluster unavailable" would send the reader the wrong way.
121
- if (errorCodeOf(error) === 'kind_sync_failed') {
123
+ if (canonicalReadCode(errorCodeOf(error)) === 'kind_sync_timed_out') {
122
124
  return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
123
125
  }
126
+ // Shares the collector's action, but it is the cluster connection
127
+ // that is down rather than one read.
128
+ if (errorCodeOf(error) === 'cluster_disconnected') {
129
+ return { headline: 'Cluster unavailable', detail: error.message, icon: ServerCrash }
130
+ }
131
+ // Radar's own identity or collection can't serve the read: neither
132
+ // the cluster being down nor the reader's access.
133
+ if (readActionFor(errorCodeOf(error)) === 'radar_config') {
134
+ return { headline: "Radar can't read this", detail: error.message, icon: ShieldOff }
135
+ }
124
136
  // The cluster is healthy and still loading — the retry policies keep
125
137
  // these surfaces polling, but a caller without one must not tell the
126
138
  // user a connecting cluster is down.
@@ -36,6 +36,26 @@ describe('MultiSelectPicker', () => {
36
36
  expect(html).toContain('>Done</button>')
37
37
  })
38
38
 
39
+ it('adds an Only button to each row only when a select-only handler is given', () => {
40
+ expect(render()).not.toContain('>Only</button>')
41
+
42
+ const html = render({ onSelectOnly: () => {} })
43
+ expect(html.match(/>Only<\/button>/g)).toHaveLength(KINDS.length)
44
+ expect(html).toContain('aria-label="Show only Pod"')
45
+ // The name stays inside the checkbox label, so clicking it still toggles.
46
+ expect(html).toMatch(/<label[^>]*><span[^>]*><input type="checkbox"[^>]*\/><span class="truncate">Pod<\/span><\/span><\/label><button/)
47
+ })
48
+
49
+ it('hides Only until hover or keyboard focus, and keeps it visible on touch', () => {
50
+ const html = render({ onSelectOnly: () => {} })
51
+ const only = html.match(/<button[^>]*aria-label="Show only Pod"[^>]*>/)?.[0] ?? ''
52
+ expect(only).toContain('opacity-0')
53
+ expect(only).toContain('group-hover/row:opacity-100')
54
+ expect(only).toContain('group-has-[:focus-visible]/row:opacity-100')
55
+ expect(only).toContain('focus-visible:opacity-100')
56
+ expect(only).toContain('any-pointer-coarse:opacity-100')
57
+ })
58
+
39
59
  it('shows the filter input above the threshold and hides it below', () => {
40
60
  const shown = render()
41
61
  expect(shown).toContain('placeholder="Filter kinds"')
@@ -16,6 +16,12 @@ export interface MultiSelectPickerProps {
16
16
  onClearAll: () => void
17
17
  /** Footer "Done" button. */
18
18
  onDone: () => void
19
+ /**
20
+ * When set, each row gets an "Only" button that selects just that item. The
21
+ * name and checkbox still toggle. The host decides whether it also commits
22
+ * and closes.
23
+ */
24
+ onSelectOnly?: (item: string) => void
19
25
 
20
26
  /** Controlled search term (kept in the host so the body stays SSR-testable). */
21
27
  search: string
@@ -50,6 +56,7 @@ export function MultiSelectPicker({
50
56
  onSelectionChange,
51
57
  onClearAll,
52
58
  onDone,
59
+ onSelectOnly,
53
60
  search,
54
61
  onSearchChange,
55
62
  searchPlaceholder,
@@ -136,8 +143,8 @@ export function MultiSelectPicker({
136
143
  )}
137
144
 
138
145
  {filteredItems.map(item => (
139
- <li key={item}>
140
- <label className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer">
146
+ <li key={item} className="group/row flex items-center hover:bg-theme-hover">
147
+ <label className="flex-1 min-w-0 flex items-center px-3 py-1.5 text-sm text-left text-theme-text-primary cursor-pointer">
141
148
  <span className="flex items-center gap-2 min-w-0">
142
149
  <input
143
150
  type="checkbox"
@@ -149,6 +156,17 @@ export function MultiSelectPicker({
149
156
  {renderItemMeta?.(item)}
150
157
  </span>
151
158
  </label>
159
+ {onSelectOnly && (
160
+ // Touchscreens can coexist with a hover-capable mouse.
161
+ <button
162
+ type="button"
163
+ onClick={() => onSelectOnly(item)}
164
+ aria-label={`Show only ${item}`}
165
+ className="shrink-0 mr-2 px-1.5 py-0.5 rounded border border-theme-border bg-theme-surface text-[11px] text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary opacity-0 transition-opacity group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 focus-visible:opacity-100 any-pointer-coarse:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
166
+ >
167
+ Only
168
+ </button>
169
+ )}
152
170
  </li>
153
171
  ))}
154
172
  </ul>
@@ -18,7 +18,9 @@ interface PageHeaderProps {
18
18
 
19
19
  export function PageHeader({ icon: Icon, title, description, onBack, actions }: PageHeaderProps) {
20
20
  return (
21
- <div className="flex items-center gap-4">
21
+ // Phones: actions (freshness, summary tiles) wrap under the title
22
+ // instead of squeezing it to nothing.
23
+ <div className="flex items-center gap-4 phone:flex-wrap phone:gap-y-3">
22
24
  {onBack && (
23
25
  <button
24
26
  onClick={onBack}
@@ -28,16 +30,16 @@ export function PageHeader({ icon: Icon, title, description, onBack, actions }:
28
30
  <ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
29
31
  </button>
30
32
  )}
31
- <div className="flex-1 min-w-0">
33
+ <div className="flex-1 min-w-0 phone:basis-full">
32
34
  <div className="flex items-center gap-2">
33
35
  <Icon className="w-5 h-5 text-theme-text-secondary shrink-0" />
34
36
  <h1 className="text-lg font-semibold text-theme-text-primary truncate">{title}</h1>
35
37
  </div>
36
38
  {description && (
37
- <p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate">{description}</p>
39
+ <p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate phone:whitespace-normal phone:line-clamp-2">{description}</p>
38
40
  )}
39
41
  </div>
40
- {actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0">{actions}</div>}
42
+ {actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0 phone:shrink phone:justify-start">{actions}</div>}
41
43
  </div>
42
44
  )
43
45
  }
@@ -1,4 +1,4 @@
1
- import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'
1
+ import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from 'react'
2
2
  import { DURATION_TOAST_EXIT } from '../../utils/animation'
3
3
  import { Check, Terminal, X, AlertTriangle, Info } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
@@ -15,15 +15,13 @@ interface Toast {
15
15
  onDetailClick?: () => void
16
16
  }
17
17
 
18
+ type ToastOptions = Omit<Toast, 'id' | 'message' | 'dismissing'> & {
19
+ id?: string
20
+ duration?: number
21
+ }
22
+
18
23
  interface ToastContextType {
19
- showToast: (message: string, options?: {
20
- detail?: string
21
- command?: string
22
- type?: Toast['type']
23
- position?: { x: number; y: number }
24
- action?: Toast['action']
25
- onDetailClick?: () => void
26
- }) => void
24
+ showToast: (message: string, options?: ToastOptions) => void
27
25
  showCopied: (command: string, label?: string, event?: React.MouseEvent) => void
28
26
  showError: (message: string, detail?: string) => void
29
27
  showSuccess: (message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) => void
@@ -36,6 +34,7 @@ class ToastManager {
36
34
  private static instance: ToastManager
37
35
  private showErrorFn: ((message: string, detail?: string) => void) | null = null
38
36
  private showSuccessFn: ((message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) => void) | null = null
37
+ private showToastFn: ToastContextType['showToast'] | null = null
39
38
 
40
39
  static getInstance(): ToastManager {
41
40
  if (!ToastManager.instance) {
@@ -44,14 +43,16 @@ class ToastManager {
44
43
  return ToastManager.instance
45
44
  }
46
45
 
47
- register(showError: typeof this.showErrorFn, showSuccess: typeof this.showSuccessFn) {
46
+ register(showError: typeof this.showErrorFn, showSuccess: typeof this.showSuccessFn, showToast: ToastContextType['showToast']) {
48
47
  this.showErrorFn = showError
49
48
  this.showSuccessFn = showSuccess
49
+ this.showToastFn = showToast
50
50
  }
51
51
 
52
52
  unregister() {
53
53
  this.showErrorFn = null
54
54
  this.showSuccessFn = null
55
+ this.showToastFn = null
55
56
  }
56
57
 
57
58
  showError(message: string, detail?: string) {
@@ -61,6 +62,10 @@ class ToastManager {
61
62
  showSuccess(message: string, detail?: string, action?: Toast['action'], onDetailClick?: () => void) {
62
63
  this.showSuccessFn ? this.showSuccessFn(message, detail, action, onDetailClick) : console.log('[Toast]', message, detail)
63
64
  }
65
+
66
+ showNotice(message: string, detail: string | undefined, type: 'warning' | 'info') {
67
+ this.showToastFn ? this.showToastFn(message, { detail, type }) : console.warn('[Toast]', message, detail)
68
+ }
64
69
  }
65
70
 
66
71
  const toastManager = ToastManager.getInstance()
@@ -73,6 +78,10 @@ export function showApiSuccess(message: string, detail?: string, action?: Toast[
73
78
  toastManager.showSuccess(message, detail, action, onDetailClick)
74
79
  }
75
80
 
81
+ export function showApiNotice(message: string, detail: string | undefined, type: 'warning' | 'info') {
82
+ toastManager.showNotice(message, detail, type)
83
+ }
84
+
76
85
  export function useToast() {
77
86
  const context = useContext(ToastContext)
78
87
  if (!context) {
@@ -83,34 +92,42 @@ export function useToast() {
83
92
 
84
93
  export function ToastProvider({ children }: { children: ReactNode }) {
85
94
  const [toasts, setToasts] = useState<Toast[]>([])
95
+ const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())
86
96
 
87
97
  const animateDismiss = useCallback((id: string) => {
98
+ clearTimeout(timers.current.get(id))
88
99
  setToasts(prev => prev.map(t => t.id === id ? { ...t, dismissing: true } : t))
89
- setTimeout(() => {
100
+ timers.current.set(id, setTimeout(() => {
90
101
  setToasts(prev => prev.filter(t => t.id !== id))
91
- }, DURATION_TOAST_EXIT)
102
+ timers.current.delete(id)
103
+ }, DURATION_TOAST_EXIT))
92
104
  }, [])
93
105
 
94
- const showToast = useCallback((message: string, options?: {
95
- detail?: string
96
- command?: string
97
- type?: Toast['type']
98
- position?: { x: number; y: number }
99
- action?: Toast['action']
100
- onDetailClick?: () => void
101
- }) => {
102
- const id = Math.random().toString(36).slice(2)
103
- const toast: Toast = { id, message, ...options }
106
+ const showToast = useCallback((message: string, options?: ToastOptions) => {
107
+ const { id = Math.random().toString(36).slice(2), duration, ...toastOptions } = options ?? {}
108
+ const toast: Toast = { id, message, ...toastOptions }
104
109
 
105
- setToasts(prev => [...prev, toast])
110
+ clearTimeout(timers.current.get(id))
111
+ timers.current.delete(id)
112
+ setToasts(prev => prev.some(t => t.id === id)
113
+ ? prev.map(t => t.id === id ? toast : t)
114
+ : [...prev, toast])
106
115
 
107
116
  // Auto-dismiss: errors stay longer (10s), others 7s
108
- const dismissTime = options?.type === 'error' ? 10000 : 7000
109
- setTimeout(() => {
110
- animateDismiss(id)
111
- }, dismissTime)
117
+ const dismissTime = duration ?? (toast.type === 'error' ? 10000 : 7000)
118
+ if (dismissTime > 0) {
119
+ timers.current.set(id, setTimeout(() => animateDismiss(id), dismissTime))
120
+ }
112
121
  }, [animateDismiss])
113
122
 
123
+ useEffect(() => {
124
+ const activeTimers = timers.current
125
+ return () => {
126
+ activeTimers.forEach(clearTimeout)
127
+ activeTimers.clear()
128
+ }
129
+ }, [])
130
+
114
131
  const showCopied = useCallback((command: string, label?: string, event?: React.MouseEvent) => {
115
132
  navigator.clipboard.writeText(command)
116
133
 
@@ -138,9 +155,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
138
155
 
139
156
  // Wire up singleton for use outside React components
140
157
  useEffect(() => {
141
- toastManager.register(showError, showSuccess)
158
+ toastManager.register(showError, showSuccess, showToast)
142
159
  return () => toastManager.unregister()
143
- }, [showError, showSuccess])
160
+ }, [showError, showSuccess, showToast])
144
161
 
145
162
  return (
146
163
  <ToastContext.Provider value={{ showToast, showCopied, showError, showSuccess }}>
@@ -176,6 +193,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
176
193
 
177
194
  return (
178
195
  <div
196
+ role="status"
179
197
  className={clsx(
180
198
  'flex items-start gap-3 p-4 rounded-lg shadow-2xl border backdrop-blur-sm',
181
199
  'w-[480px] max-w-[calc(100vw-32px)]',
@@ -233,7 +251,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
233
251
  {toast.detail}
234
252
  </button>
235
253
  ) : (
236
- <p className={clsx('mt-1 text-xs break-words', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
254
+ <p className={clsx('mt-1 text-xs break-words whitespace-pre-line', isError ? 'text-red-300/80' : isSuccess ? 'text-emerald-300/80' : 'text-theme-text-secondary')}>
237
255
  {toast.detail}
238
256
  </p>
239
257
  )