@skyhook-io/k8s-ui 1.8.10 → 1.8.11

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 (116) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +3 -3
  17. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  18. package/src/components/gitops/SyncCountdown.tsx +26 -17
  19. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  20. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  21. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  22. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  23. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  24. package/src/components/issues/IssuesView.tsx +51 -38
  25. package/src/components/issues/ResourceIssuesSection.test.tsx +6 -5
  26. package/src/components/issues/ResourceIssuesSection.tsx +28 -146
  27. package/src/components/issues/index.ts +3 -0
  28. package/src/components/issues/issue-timing.ts +53 -0
  29. package/src/components/issues/issues.test.ts +102 -1
  30. package/src/components/issues/severity.ts +2 -0
  31. package/src/components/logs/LogCore.tsx +14 -4
  32. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  33. package/src/components/logs/useLogStream.ts +8 -1
  34. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  35. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  37. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  38. package/src/components/resources/ResourcesView.tsx +233 -51
  39. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  41. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  42. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  44. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  45. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  46. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  48. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  49. package/src/components/resources/renderers/index.ts +1 -0
  50. package/src/components/resources/resource-utils-capi.ts +1 -1
  51. package/src/components/resources/resource-utils.ts +31 -0
  52. package/src/components/shared/DetailShell.tsx +2 -2
  53. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  54. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  55. package/src/components/timeline/TimelineList.tsx +371 -354
  56. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  57. package/src/components/timeline/TimelineStrip.tsx +836 -0
  58. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  59. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  60. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  61. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  62. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  63. package/src/components/timeline/index.ts +23 -0
  64. package/src/components/timeline/scrubber-math.test.ts +235 -0
  65. package/src/components/timeline/scrubber-math.ts +249 -0
  66. package/src/components/timeline/shared.test.tsx +219 -0
  67. package/src/components/timeline/shared.tsx +232 -447
  68. package/src/components/timeline/timeline-filters.test.ts +196 -0
  69. package/src/components/timeline/timeline-filters.ts +180 -0
  70. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  71. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  72. package/src/components/timeline/timeline-live.test.ts +131 -0
  73. package/src/components/timeline/timeline-live.ts +77 -0
  74. package/src/components/topology/GroupNode.tsx +9 -2
  75. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  76. package/src/components/topology/K8sResourceNode.tsx +387 -243
  77. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  78. package/src/components/topology/TopologyGraph.tsx +89 -16
  79. package/src/components/topology/TopologySearch.tsx +2 -2
  80. package/src/components/topology/layout.ts +10 -2
  81. package/src/components/topology/topology.css +0 -92
  82. package/src/components/ui/Badge.tsx +5 -0
  83. package/src/components/ui/CodeViewer.tsx +2 -2
  84. package/src/components/ui/Collapse.tsx +59 -0
  85. package/src/components/ui/ConfirmDialog.tsx +7 -3
  86. package/src/components/ui/DialogPortal.tsx +31 -9
  87. package/src/components/ui/Input.tsx +24 -0
  88. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  89. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  90. package/src/components/ui/PaneLoader.tsx +10 -2
  91. package/src/components/ui/ResourceBar.tsx +68 -25
  92. package/src/components/ui/SearchBox.tsx +18 -4
  93. package/src/components/ui/SearchPillInput.tsx +2 -2
  94. package/src/components/ui/drawer-components.tsx +39 -26
  95. package/src/components/ui/index.ts +4 -0
  96. package/src/components/ui/severity-tone.ts +11 -4
  97. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  98. package/src/components/workload/WorkloadView.tsx +2682 -472
  99. package/src/components/workload/index.ts +1 -1
  100. package/src/theme/variables.css +1 -1
  101. package/src/types/core.ts +57 -4
  102. package/src/utils/application-topology.test.ts +198 -0
  103. package/src/utils/application-topology.ts +261 -0
  104. package/src/utils/applications.test.ts +167 -1
  105. package/src/utils/applications.ts +1157 -393
  106. package/src/utils/index.ts +2 -0
  107. package/src/utils/navigation.test.ts +32 -1
  108. package/src/utils/navigation.ts +70 -0
  109. package/src/utils/resource-hierarchy.test.ts +990 -1
  110. package/src/utils/resource-hierarchy.ts +905 -267
  111. package/src/utils/resource-icons.ts +2 -0
  112. package/src/utils/topology-neighborhood.test.ts +202 -1
  113. package/src/utils/topology-neighborhood.ts +194 -13
  114. package/src/utils/workflow-execution.test.ts +556 -0
  115. package/src/utils/workflow-execution.ts +814 -0
  116. package/src/utils/workload-colors.ts +2 -3
@@ -4,6 +4,7 @@ import { renderToString } from 'react-dom/server'
4
4
  import { compareIssues, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
5
5
  import { categoryLabel, groupBadgeClass, groupLabel } from './severity'
6
6
  import { IssueRow } from './IssuesView'
7
+ import { issueTiming } from './issue-timing'
7
8
 
8
9
  const base: Issue = {
9
10
  id: 'id-0',
@@ -78,7 +79,7 @@ describe('category/group label fallbacks', () => {
78
79
  })
79
80
 
80
81
  describe('IssueRow', () => {
81
- it('keeps relative age visible and adds since-deploy as a secondary tag', () => {
82
+ it('keeps relative age visible and adds deployment timing as a secondary tag', () => {
82
83
  vi.useFakeTimers()
83
84
  vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
84
85
 
@@ -96,6 +97,25 @@ describe('IssueRow', () => {
96
97
  expect(html).toContain('since deploy')
97
98
  })
98
99
 
100
+ it('promotes after-healthy timing in the collapsed row', () => {
101
+ vi.useFakeTimers()
102
+ vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
103
+
104
+ const html = renderToString(createElement(IssueRow, {
105
+ issue: mk({
106
+ id: 'regression',
107
+ first_seen: '2026-06-30T10:00:00Z',
108
+ issue_timing: 'started_after_resource_was_healthy',
109
+ issue_timing_basis: 'condition',
110
+ }),
111
+ open: false,
112
+ onToggle: () => undefined,
113
+ }))
114
+
115
+ expect(html).toContain('2h')
116
+ expect(html).toContain('after healthy')
117
+ })
118
+
99
119
  it('uses the category-group chip class in the queue row', () => {
100
120
  const html = renderToString(createElement(IssueRow, {
101
121
  issue: mk({ category_group: 'control_plane' }),
@@ -108,6 +128,87 @@ describe('IssueRow', () => {
108
128
  })
109
129
  })
110
130
 
131
+ describe('issueTiming', () => {
132
+ it('keeps deployment wording for deployment-like creation failures', () => {
133
+ expect(issueTiming(mk({
134
+ kind: 'Deployment',
135
+ group: 'apps',
136
+ issue_timing: 'started_at_resource_creation',
137
+ issue_timing_basis: 'condition',
138
+ }))).toMatchObject({
139
+ kind: 'creation',
140
+ chip: 'since deploy',
141
+ meta: 'present since deployment or first reconciliation',
142
+ })
143
+
144
+ expect(issueTiming(mk({
145
+ kind: 'Rollout',
146
+ group: 'argoproj.io',
147
+ issue_timing: 'started_at_resource_creation',
148
+ issue_timing_basis: 'condition',
149
+ }))).toMatchObject({
150
+ kind: 'creation',
151
+ chip: 'since deploy',
152
+ meta: 'present since deployment or first reconciliation',
153
+ })
154
+ })
155
+
156
+ it('uses deployment wording for pod issues only when workload timing evidence is present', () => {
157
+ expect(issueTiming(mk({
158
+ kind: 'Pod',
159
+ issue_timing: 'started_at_resource_creation',
160
+ issue_timing_basis: 'pod_creation',
161
+ }))).toMatchObject({
162
+ kind: 'creation',
163
+ chip: 'since deploy',
164
+ })
165
+
166
+ expect(issueTiming(mk({
167
+ kind: 'Pod',
168
+ issue_timing: 'started_at_resource_creation',
169
+ issue_timing_basis: 'phase',
170
+ }))).toMatchObject({
171
+ kind: 'creation',
172
+ chip: 'since creation',
173
+ })
174
+ })
175
+
176
+ it('uses resource creation wording for non-deployment creation failures', () => {
177
+ expect(issueTiming(mk({
178
+ kind: 'PersistentVolumeClaim',
179
+ issue_timing: 'started_at_resource_creation',
180
+ issue_timing_basis: 'phase',
181
+ }))).toMatchObject({
182
+ kind: 'creation',
183
+ chip: 'since creation',
184
+ meta: 'present since creation or first reconciliation',
185
+ })
186
+ })
187
+
188
+ it('describes after-healthy timing without implying root cause or safety', () => {
189
+ vi.useFakeTimers()
190
+ vi.setSystemTime(new Date('2026-06-30T12:00:00Z'))
191
+
192
+ const display = issueTiming(mk({
193
+ first_seen: '2026-06-30T10:00:00Z',
194
+ issue_timing: 'started_after_resource_was_healthy',
195
+ issue_timing_basis: 'condition',
196
+ }))
197
+
198
+ expect(display).toMatchObject({
199
+ kind: 'regression',
200
+ chip: 'after healthy',
201
+ meta: 'started 2h ago after being healthy',
202
+ tooltip: 'Previously healthy before this failing signal.',
203
+ })
204
+ expect(`${display?.chip} ${display?.meta} ${display?.tooltip}`).not.toMatch(/baseline|safe|ignore/i)
205
+ })
206
+
207
+ it('returns null when there is no confident timing signal', () => {
208
+ expect(issueTiming(mk({ issue_timing: undefined, issue_timing_basis: undefined }))).toBeNull()
209
+ })
210
+ })
211
+
111
212
  describe('subjectRef / memberRef', () => {
112
213
  it('subjectRef defaults empty group/namespace and threads cluster_id', () => {
113
214
  const issue = mk({ cluster_id: 'cl_1', kind: 'Deployment', name: 'web' }) // no group/namespace
@@ -2,6 +2,7 @@ import type { IssueSeverity } from './types';
2
2
  import { BADGE_SEVERITY_COLORS as sev } from '../ui/Badge';
3
3
  import { NEUTRAL_CHIP_CLASS } from '../ui/CardSection';
4
4
  import {
5
+ TONE_FILL_CLASS,
5
6
  TONE_HEADER_BAND_CLASS,
6
7
  TONE_RAIL_CLASS,
7
8
  TONE_SOLID_CLASS,
@@ -36,6 +37,7 @@ export const ISSUE_SEVERITY_BADGE_CLASS: Record<IssueSeverity, string> = {
36
37
  };
37
38
 
38
39
  export const ISSUE_SEVERITY_TEXT_CLASS = byTone(TONE_TEXT_CLASS);
40
+ export const ISSUE_SEVERITY_FILL_CLASS = byTone(TONE_FILL_CLASS);
39
41
  export const ISSUE_SEVERITY_RAIL_CLASS = byTone(TONE_RAIL_CLASS);
40
42
  export const ISSUE_SEVERITY_SOLID_CLASS = byTone(TONE_SOLID_CLASS);
41
43
  export const ISSUE_SEVERITY_HEADER_BAND_CLASS = byTone(TONE_HEADER_BAND_CLASS);
@@ -5,6 +5,7 @@ import type { LogEntry, LogLevel } from './useLogBuffer'
5
5
  import { useLogSearch } from './useLogSearch'
6
6
  import { StructuredLogLine } from './StructuredLogLine'
7
7
  import { Tooltip } from '../ui/Tooltip'
8
+ import { Input } from '../ui/Input'
8
9
  import {
9
10
  formatLogTimestamp,
10
11
  highlightSearchMatches,
@@ -37,6 +38,7 @@ interface LogCoreProps {
37
38
  toolbarExtra?: ToolbarExtraRenderer
38
39
  showPodName?: boolean
39
40
  emptyMessage?: string
41
+ emptyCommand?: string | null
40
42
  errorMessage?: string | null
41
43
  /**
42
44
  * Hard override for the viewer palette. When set, the viewer stays pinned to
@@ -88,6 +90,8 @@ const STRUCTURED_MODE_DESCRIPTIONS: Record<StructuredMode, string> = {
88
90
  raw: 'Original log line, unparsed',
89
91
  }
90
92
 
93
+ const EMPTY_STATE_CLASS = 'flex-1 flex flex-col items-center justify-center gap-2 px-4 text-center'
94
+
91
95
  const TIMESTAMP_FORMAT_SHORT_LABELS: Record<TimestampFormat, string> = {
92
96
  'time-local': 'Local time',
93
97
  'time-utc': 'UTC time',
@@ -124,6 +128,7 @@ export function LogCore({
124
128
  toolbarExtra,
125
129
  showPodName = false,
126
130
  emptyMessage = 'No logs available',
131
+ emptyCommand,
127
132
  errorMessage,
128
133
  forceDark,
129
134
  }: LogCoreProps) {
@@ -694,8 +699,7 @@ export function LogCore({
694
699
  {search.isOpen && (
695
700
  <div className={`flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBgMuted}`}>
696
701
  <Search className={`w-4 h-4 ${palette.textSecondary} shrink-0`} />
697
- <input
698
- type="text"
702
+ <Input
699
703
  value={search.query}
700
704
  onChange={(e) => search.setQuery(e.target.value)}
701
705
  onKeyDown={(e) => {
@@ -800,14 +804,20 @@ export function LogCore({
800
804
  </div>
801
805
  </div>
802
806
  ) : errorMessage ? (
803
- <div className={`flex-1 flex flex-col items-center justify-center gap-2 ${palette.textError}`}>
807
+ <div className={`${EMPTY_STATE_CLASS} ${palette.textError}`}>
804
808
  <Terminal className="w-8 h-8" />
805
809
  <span>{errorMessage}</span>
806
810
  </div>
807
811
  ) : groupedEntries.length === 0 ? (
808
- <div className={`flex-1 flex flex-col items-center justify-center gap-2 ${palette.textTertiary}`}>
812
+ <div className={`${EMPTY_STATE_CLASS} ${palette.textTertiary}`}>
809
813
  <Terminal className="w-8 h-8" />
810
814
  <span>{emptyMessage}</span>
815
+ {emptyCommand && (
816
+ <button type="button" onClick={() => navigator.clipboard.writeText(emptyCommand).catch(() => {})} className={`mt-2 inline-flex max-w-[80%] items-center gap-2 rounded border px-3 py-2 font-mono text-xs ${palette.border} ${palette.toolbarBg}`} title="Copy recovery command">
817
+ <code className="truncate">{emptyCommand}</code>
818
+ <Copy className="h-3.5 w-3.5 shrink-0" />
819
+ </button>
820
+ )}
811
821
  </div>
812
822
  ) : (
813
823
  <div className="flex-1 relative">
@@ -27,6 +27,9 @@ export interface WorkloadLogsFetchParams {
27
27
  export interface WorkloadLogsResult {
28
28
  pods: WorkloadPodInfo[]
29
29
  logs: WorkloadRawLog[]
30
+ emptyReason?: string
31
+ emptyMessage?: string
32
+ command?: string
30
33
  }
31
34
 
32
35
  export interface WorkloadLogsViewerProps {
@@ -61,6 +64,8 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
61
64
  const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
62
65
  const [isLoading, setIsLoading] = useState(false)
63
66
  const [fetchError, setFetchError] = useState<string | null>(null)
67
+ const [emptyMessage, setEmptyMessage] = useState<string | null>(null)
68
+ const [emptyCommand, setEmptyCommand] = useState<string | null>(null)
64
69
  const [showPodFilter, setShowPodFilter] = useState(false)
65
70
  const [logRange, setLogRange] = useState('100')
66
71
  const { showError, showSuccess } = useToast()
@@ -95,9 +100,10 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
95
100
  setFetchError(null)
96
101
  try {
97
102
  const result = await fetchAll({ container: selectedContainer || undefined, tailLines, sinceSeconds })
98
- // Older backends marshal empty results as null rather than [].
99
103
  const resultPods = result.pods ?? []
100
104
  const resultLogs = result.logs ?? []
105
+ setEmptyMessage(result.emptyMessage || null)
106
+ setEmptyCommand(result.command || null)
101
107
 
102
108
  podColorIndexRef.current = new Map(resultPods.map((pod, i) => [pod.name, i]))
103
109
  setPods(resultPods)
@@ -157,6 +163,8 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
157
163
  const nextPods = data.pods as WorkloadPodInfo[]
158
164
  podColorIndexRef.current = new Map(nextPods.map((pod, i) => [pod.name, i]))
159
165
  setPods(nextPods)
166
+ setEmptyMessage(data.emptyMessage || null)
167
+ setEmptyCommand(data.command || null)
160
168
  setSelectedPods(prev => (
161
169
  prev.size === 0 ? new Set(nextPods.map((p: WorkloadPodInfo) => p.name)) : prev
162
170
  ))
@@ -180,8 +188,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
180
188
  const existing = new Set(prev.map(p => p.name))
181
189
  const toAdd = newPods.filter(p => !existing.has(p.name))
182
190
  if (toAdd.length === 0) return prev
183
- return [...prev, ...toAdd]
191
+ const next = [...prev, ...toAdd]
192
+ podColorIndexRef.current = new Map(next.map((pod, i) => [pod.name, i]))
193
+ return next
184
194
  })
195
+ setEmptyMessage(null)
196
+ setEmptyCommand(null)
185
197
  setSelectedPods(prev => {
186
198
  const next = new Set(prev)
187
199
  newPods.forEach(p => next.add(p.name))
@@ -197,6 +209,10 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
197
209
  // while new pod logs start flowing in
198
210
  }
199
211
  },
212
+ onEnd: (data: any) => {
213
+ if (data?.emptyMessage) setEmptyMessage(data.emptyMessage)
214
+ if (data?.command) setEmptyCommand(data.command)
215
+ },
200
216
  },
201
217
  'Workload log stream connection failed',
202
218
  )
@@ -299,8 +315,13 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
299
315
  </div>
300
316
  {pods.map(pod => {
301
317
  const dotBg = palette.podColors[(podColorIndex.get(pod.name) ?? 0) % palette.podColors.length].bg
318
+ const primaryLabel = pod.stepName || pod.name
319
+ const secondaryLabel = pod.stepName ? pod.name : ''
320
+ const stateLabel = pod.stepPhase || pod.phase || (pod.ready ? 'Ready' : 'Not Ready')
302
321
  let readyColor: string
303
- if (pod.ready) {
322
+ if (stateLabel === 'Failed' || stateLabel === 'Error') {
323
+ readyColor = isDark ? 'text-red-400' : 'text-red-700'
324
+ } else if (pod.ready || stateLabel === 'Succeeded') {
304
325
  readyColor = isDark ? 'text-emerald-400' : 'text-emerald-700'
305
326
  } else {
306
327
  readyColor = isDark ? 'text-amber-400' : 'text-amber-700'
@@ -314,9 +335,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
314
335
  className={`w-3 h-3 rounded ${palette.borderLight} ${palette.elevatedBg} text-blue-500 focus:ring-blue-500 focus:ring-offset-0`}
315
336
  />
316
337
  <span className={`w-2 h-2 rounded-full ${dotBg}`} />
317
- <span className={`text-xs ${palette.textPrimary} truncate flex-1`}>{pod.name}</span>
338
+ <span className="min-w-0 flex-1">
339
+ <span className={`block truncate text-xs ${palette.textPrimary}`}>{primaryLabel}</span>
340
+ {secondaryLabel && <span className={`block truncate text-[10px] ${palette.textTertiary}`}>{secondaryLabel}</span>}
341
+ </span>
318
342
  <span className={`text-xs ${readyColor}`}>
319
- {pod.ready ? 'Ready' : 'Not Ready'}
343
+ {stateLabel}
320
344
  </span>
321
345
  </label>
322
346
  )
@@ -360,7 +384,8 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
360
384
  onClear={clear}
361
385
  toolbarExtra={renderToolbarExtra}
362
386
  showPodName
363
- emptyMessage={pods.length === 0 ? 'No pods found' : 'No logs available'}
387
+ emptyMessage={emptyMessage || (pods.length === 0 ? 'No pods found' : 'No logs available')}
388
+ emptyCommand={emptyCommand}
364
389
  errorMessage={fetchError || (entries.length === 0 ? streamError : null)}
365
390
  forceDark={forceDark}
366
391
  />
@@ -10,6 +10,8 @@ export interface LogStreamHandlers {
10
10
  onPodAdded?: (data: unknown) => void
11
11
  /** Called when pods are terminated during streaming (workload logs only) */
12
12
  onPodRemoved?: (data: unknown) => void
13
+ /** Called when the server ends the stream cleanly */
14
+ onEnd?: (data: unknown) => void
13
15
  }
14
16
 
15
17
  /**
@@ -90,11 +92,16 @@ export function useLogStream() {
90
92
  }
91
93
  })
92
94
 
93
- es.addEventListener('end', () => {
95
+ es.addEventListener('end', (event) => {
94
96
  if (!isCurrent()) return
95
97
  endedRef.current = true
96
98
  setIsStreaming(false)
97
99
  setConnecting(false)
100
+ if (handlers.onEnd) {
101
+ try { handlers.onEnd(JSON.parse((event as MessageEvent).data)) } catch (e) {
102
+ console.error('Failed to parse end event:', e)
103
+ }
104
+ }
98
105
  })
99
106
 
100
107
  es.addEventListener('error', (event) => {
@@ -1,7 +1,8 @@
1
1
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { ChevronDown, Globe, Search, AlertTriangle, X } from 'lucide-react'
3
+ import { ChevronDown, Globe, Search, AlertTriangle } from 'lucide-react'
4
4
  import { Tooltip } from '../ui/Tooltip'
5
+ import { MultiSelectPicker } from '../ui/MultiSelectPicker'
5
6
 
6
7
  /**
7
8
  * Backend-reported namespace scope. Mirrors Radar's `/cluster/namespace-scope`
@@ -192,18 +193,6 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
192
193
  applySelection(new Set())
193
194
  }
194
195
 
195
- const selectAllVisible = () => {
196
- const next = new Set(draft)
197
- for (const ns of filteredItems) next.add(ns)
198
- setDraft(next)
199
- }
200
-
201
- const clearVisible = () => {
202
- const next = new Set(draft)
203
- for (const ns of filteredItems) next.delete(ns)
204
- setDraft(next)
205
- }
206
-
207
196
  const activeCount = scopeActives.length
208
197
  const triggerLabel =
209
198
  activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
@@ -226,11 +215,6 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
226
215
  ? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
227
216
  : `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
228
217
 
229
- // Counts used to label the bulk-action buttons; computed against the visible
230
- // (filtered) set so the labels match what the action will affect.
231
- const visibleSelectedCount = filteredItems.reduce((n, ns) => n + (draft.has(ns) ? 1 : 0), 0)
232
- const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
233
-
234
218
  return (
235
219
  <>
236
220
  <Tooltip
@@ -271,100 +255,95 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
271
255
  style={{ position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width, zIndex: 100 }}
272
256
  className="bg-theme-surface border border-theme-border rounded-md shadow-theme-lg overflow-hidden"
273
257
  >
274
- {items.length > 6 && (
275
- <div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
276
- <Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
277
- <input
278
- autoFocus
279
- value={search}
280
- onChange={e => setSearch(e.target.value)}
281
- placeholder="Filter namespaces"
282
- className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
283
- />
284
- </div>
285
- )}
286
-
287
258
  {scope.cacheScoped ? (
288
- <div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
289
- Radar is watching one namespace to stay fast on large clusters.
290
- {scope.namespaceRescope
291
- ? ' Pick another to re-point it — takes a moment and closes open terminals.'
292
- : ' This instance is locked to its startup namespace.'}
293
- </div>
294
- ) : (
295
- <div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
296
- <button
297
- onClick={canClearAll ? clearAll : undefined}
298
- disabled={!canClearAll || activeCount === 0}
299
- className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
300
- aria-label="Clear namespace selection"
301
- >
302
- <X className="w-3 h-3" />
303
- Clear all
304
- </button>
305
- <button
306
- onClick={allVisibleSelected ? clearVisible : selectAllVisible}
307
- disabled={filteredItems.length === 0}
308
- className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
309
- >
310
- {allVisibleSelected
311
- ? `Clear ${filteredItems.length} visible`
312
- : search.trim()
313
- ? `Select ${filteredItems.length} visible`
314
- : 'Select all'}
315
- </button>
316
- </div>
317
- )}
259
+ <>
260
+ {items.length > 6 && (
261
+ <div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
262
+ <Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
263
+ <input
264
+ autoFocus
265
+ value={search}
266
+ onChange={e => setSearch(e.target.value)}
267
+ placeholder="Filter namespaces"
268
+ className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
269
+ />
270
+ </div>
271
+ )}
272
+
273
+ <div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
274
+ Radar is watching one namespace to stay fast on large clusters.
275
+ {scope.namespaceRescope
276
+ ? ' Pick another to re-point it — takes a moment and closes open terminals.'
277
+ : ' This instance is locked to its startup namespace.'}
278
+ </div>
318
279
 
319
- <ul className="max-h-80 overflow-y-auto py-1">
320
- {filteredItems.length === 0 && (
321
- <li className="px-3 py-2 text-xs text-theme-text-tertiary">
322
- {search ? 'No matches.' : 'No namespaces available.'}
323
- </li>
324
- )}
280
+ <ul className="max-h-80 overflow-y-auto py-1">
281
+ {filteredItems.length === 0 && (
282
+ <li className="px-3 py-2 text-xs text-theme-text-tertiary">
283
+ {search ? 'No matches.' : 'No namespaces available.'}
284
+ </li>
285
+ )}
325
286
 
326
- {filteredItems.map(ns => {
327
- const isChecked = draft.has(ns)
328
- const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
329
- return (
330
- <li key={ns}>
331
- <label
332
- 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"
333
- >
334
- <span className="flex items-center gap-2 min-w-0">
335
- <input
336
- type={scope.cacheScoped ? 'radio' : 'checkbox'}
337
- name={scope.cacheScoped ? 'namespace-cache-scope' : undefined}
338
- checked={isChecked}
339
- onChange={() => toggle(ns)}
340
- className="shrink-0 accent-current"
341
- />
342
- <span className="truncate">{ns}</span>
343
- {isContextDefault && (
344
- <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
345
- kubeconfig
287
+ {filteredItems.map(ns => {
288
+ const isChecked = draft.has(ns)
289
+ const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
290
+ return (
291
+ <li key={ns}>
292
+ <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">
293
+ <span className="flex items-center gap-2 min-w-0">
294
+ <input
295
+ type="radio"
296
+ name="namespace-cache-scope"
297
+ checked={isChecked}
298
+ onChange={() => toggle(ns)}
299
+ className="shrink-0 accent-current"
300
+ />
301
+ <span className="truncate">{ns}</span>
302
+ {isContextDefault && (
303
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
304
+ kubeconfig
305
+ </span>
306
+ )}
346
307
  </span>
347
- )}
348
- </span>
349
- </label>
350
- </li>
351
- )
352
- })}
353
- </ul>
308
+ </label>
309
+ </li>
310
+ )
311
+ })}
312
+ </ul>
354
313
 
355
- <div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
356
- <span>
357
- {scope.cacheScoped
358
- ? (draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace')
359
- : draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
360
- </span>
361
- <button
362
- onClick={closeAndApply}
363
- className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
364
- >
365
- Done
366
- </button>
367
- </div>
314
+ <div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
315
+ <span>{draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace'}</span>
316
+ <button
317
+ onClick={closeAndApply}
318
+ className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
319
+ >
320
+ Done
321
+ </button>
322
+ </div>
323
+ </>
324
+ ) : (
325
+ <MultiSelectPicker
326
+ items={items}
327
+ selected={draft}
328
+ onSelectionChange={setDraft}
329
+ onClearAll={clearAll}
330
+ onDone={closeAndApply}
331
+ search={search}
332
+ onSearchChange={setSearch}
333
+ searchPlaceholder="Filter namespaces"
334
+ summaryEmptyLabel="All namespaces"
335
+ noItemsLabel="No namespaces available."
336
+ clearAllDisabled={!canClearAll || activeCount === 0}
337
+ clearAllAriaLabel="Clear namespace selection"
338
+ renderItemMeta={ns =>
339
+ ns === scope.kubeconfigNamespace && ns !== '' ? (
340
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
341
+ kubeconfig
342
+ </span>
343
+ ) : null
344
+ }
345
+ />
346
+ )}
368
347
 
369
348
  {!scope.authoritative && (
370
349
  <div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
@@ -13,6 +13,16 @@ const sqlInstance: APIResource = {
13
13
  verbs: ['list'],
14
14
  }
15
15
 
16
+ const horizontalPodAutoscaler: APIResource = {
17
+ group: 'autoscaling',
18
+ version: 'v2',
19
+ kind: 'HorizontalPodAutoscaler',
20
+ name: 'horizontalpodautoscalers',
21
+ namespaced: true,
22
+ isCrd: false,
23
+ verbs: ['list'],
24
+ }
25
+
16
26
  describe('ResourcesSidebar CRD group labels', () => {
17
27
  it('keeps raw API groups available for filtering and hover recovery', () => {
18
28
  expect(resourceMatchesSidebarFilter(sqlInstance, 'cnrm')).toBe(true)
@@ -34,3 +44,64 @@ describe('ResourcesSidebar CRD group labels', () => {
34
44
  expect(html).toContain('title="API group: sql.cnrm.cloud.google.com"')
35
45
  })
36
46
  })
47
+
48
+ describe('ResourcesSidebar count visibility', () => {
49
+ it('keeps resources visible when their count is unknown', () => {
50
+ const html = renderToString(
51
+ <ResourcesSidebar
52
+ selectedKind={null}
53
+ onSelectedKindChange={() => {}}
54
+ apiResources={[horizontalPodAutoscaler]}
55
+ resourceCounts={{}}
56
+ />
57
+ )
58
+
59
+ expect(html).toContain('HorizontalPodAutoscaler')
60
+ expect(html).toContain('–')
61
+ expect(html).toContain('aria-label="Count unavailable. Open to view resources."')
62
+ })
63
+
64
+ it('hides confirmed-empty resources by default', () => {
65
+ const html = renderToString(
66
+ <ResourcesSidebar
67
+ selectedKind={null}
68
+ onSelectedKindChange={() => {}}
69
+ apiResources={[horizontalPodAutoscaler]}
70
+ resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
71
+ />
72
+ )
73
+
74
+ expect(html).not.toContain('HorizontalPodAutoscaler')
75
+ expect(html).toContain('Show')
76
+ })
77
+
78
+ it('keeps the selected confirmed-empty resource visible', () => {
79
+ const html = renderToString(
80
+ <ResourcesSidebar
81
+ selectedKind={{ name: 'horizontalpodautoscalers', kind: 'HorizontalPodAutoscaler', group: 'autoscaling' }}
82
+ onSelectedKindChange={() => {}}
83
+ apiResources={[horizontalPodAutoscaler]}
84
+ resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
85
+ />
86
+ )
87
+
88
+ expect(html).toContain('HorizontalPodAutoscaler')
89
+ expect(html).toContain('>0</')
90
+ })
91
+
92
+ it('keeps pinned confirmed-empty resources visible in Favorites', () => {
93
+ const html = renderToString(
94
+ <ResourcesSidebar
95
+ selectedKind={null}
96
+ onSelectedKindChange={() => {}}
97
+ apiResources={[horizontalPodAutoscaler]}
98
+ resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
99
+ pinned={[{ name: 'horizontalpodautoscalers', kind: 'HorizontalPodAutoscaler', group: 'autoscaling' }]}
100
+ isPinned={(name, group) => name === 'horizontalpodautoscalers' && group === 'autoscaling'}
101
+ />
102
+ )
103
+
104
+ expect(html).toContain('Favorites')
105
+ expect(html).toContain('HorizontalPodAutoscaler')
106
+ })
107
+ })