@skyhook-io/k8s-ui 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/README.md +41 -0
  2. package/package.json +6 -6
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +7 -3
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +142 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -81
  67. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  68. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  69. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +91 -2
  70. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +76 -76
  71. package/src/components/gitops/tree/layout.test.ts +111 -0
  72. package/src/components/gitops/tree/layout.ts +128 -0
  73. package/src/components/gitops/tree/tree-helpers.test.ts +23 -0
  74. package/src/components/gitops/tree/tree-helpers.ts +12 -2
  75. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  76. package/src/components/issues/IssuesView.tsx +44 -6
  77. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  78. package/src/components/issues/ResourceIssuesSection.tsx +8 -2
  79. package/src/components/issues/diagnostic.ts +2 -0
  80. package/src/components/issues/index.ts +3 -1
  81. package/src/components/issues/issue-timing.ts +12 -1
  82. package/src/components/issues/issues.test.ts +75 -1
  83. package/src/components/issues/severity.ts +30 -2
  84. package/src/components/issues/types.ts +55 -4
  85. package/src/components/logs/LogCore.theme.test.tsx +206 -3
  86. package/src/components/logs/LogCore.tsx +180 -53
  87. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  88. package/src/components/logs/LogsViewer.test.tsx +369 -0
  89. package/src/components/logs/LogsViewer.tsx +91 -13
  90. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  91. package/src/components/logs/StructuredLogLine.tsx +23 -31
  92. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  93. package/src/components/logs/WorkloadLogsViewer.tsx +46 -11
  94. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  95. package/src/components/logs/useLogBuffer.test.ts +8 -0
  96. package/src/components/logs/useLogBuffer.ts +48 -18
  97. package/src/components/logs/useLogSearch.ts +3 -1
  98. package/src/components/logs/useLogStream.ts +17 -1
  99. package/src/components/problems/index.ts +1 -0
  100. package/src/components/problems/problems.test.tsx +63 -0
  101. package/src/components/problems/problems.tsx +165 -0
  102. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  103. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  104. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  105. package/src/components/resources/ColumnPickerList.tsx +174 -0
  106. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  107. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  108. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  109. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  110. package/src/components/resources/ResourcesView.kind-view.test.tsx +296 -0
  111. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  112. package/src/components/resources/ResourcesView.tsx +392 -181
  113. package/src/components/resources/column-order.test.ts +104 -0
  114. package/src/components/resources/column-order.ts +64 -0
  115. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  116. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  117. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  118. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  119. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  120. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  121. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  122. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  123. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  124. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  126. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  127. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  128. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  129. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  130. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  131. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  132. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  133. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  134. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  135. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  136. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  137. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  138. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  139. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  140. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  141. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  142. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  143. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  144. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  145. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  146. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  147. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  148. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  149. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  150. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  151. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  152. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  153. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  154. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  155. package/src/components/resources/renderers/PodRenderer.tsx +81 -40
  156. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  157. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  158. package/src/components/resources/resource-utils-capi.ts +1 -1
  159. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  160. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  161. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  162. package/src/components/resources/resource-utils-contour.ts +16 -0
  163. package/src/components/resources/resource-utils-istio.ts +12 -8
  164. package/src/components/resources/resource-utils-knative.ts +18 -0
  165. package/src/components/resources/resource-utils.ts +96 -7
  166. package/src/components/resources/rules-column-width.test.ts +1 -1
  167. package/src/components/resources/summarize-scheduler-message.test.ts +31 -0
  168. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  169. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  170. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  171. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  172. package/src/components/shared/DetailShell.test.tsx +80 -0
  173. package/src/components/shared/DetailShell.tsx +85 -10
  174. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  175. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  176. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  177. package/src/components/shared/EditableYamlView.tsx +1 -1
  178. package/src/components/shared/ResourceActionsBar.tsx +14 -7
  179. package/src/components/shared/ResourceRendererDispatch.tsx +15 -10
  180. package/src/components/shared/index.ts +2 -0
  181. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  182. package/src/components/timeline/DiffViewer.tsx +113 -4
  183. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  184. package/src/components/timeline/shared.test.tsx +48 -0
  185. package/src/components/timeline/shared.tsx +14 -19
  186. package/src/components/timeline/timeline-filters.ts +1 -0
  187. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  188. package/src/components/topology/K8sResourceNode.tsx +44 -2
  189. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  190. package/src/components/topology/TopologyGraph.tsx +3 -71
  191. package/src/components/topology/TopologySearch.tsx +1 -0
  192. package/src/components/topology/internet-groups.test.ts +35 -0
  193. package/src/components/topology/internet-groups.ts +55 -0
  194. package/src/components/topology/layout.ts +1 -1
  195. package/src/components/ui/Badge.tsx +1 -0
  196. package/src/components/ui/FoldSection.tsx +68 -0
  197. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  198. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  199. package/src/components/ui/RefLink.tsx +20 -0
  200. package/src/components/ui/RowActionMenu.tsx +8 -3
  201. package/src/components/ui/Toast.tsx +15 -4
  202. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  203. package/src/components/ui/drawer-components.test.tsx +110 -1
  204. package/src/components/ui/drawer-components.tsx +62 -9
  205. package/src/components/ui/index.ts +4 -2
  206. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  207. package/src/components/ui/monacoRuntime.ts +14 -19
  208. package/src/components/ui/status-tone.test.ts +10 -1
  209. package/src/components/ui/status-tone.tsx +29 -0
  210. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  211. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  212. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  213. package/src/components/workload/WorkloadView.tsx +193 -71
  214. package/src/index.ts +14 -1
  215. package/src/monaco-deep.d.ts +19 -12
  216. package/src/theme/components.css +13 -0
  217. package/src/types/capacity.ts +11 -2
  218. package/src/types/core.ts +53 -2
  219. package/src/types/gitops-flux-status.test.ts +32 -1
  220. package/src/types/gitops-insights.ts +20 -2
  221. package/src/types/gitops-tree.ts +12 -0
  222. package/src/types/gitops.ts +26 -6
  223. package/src/utils/api-resources.ts +7 -0
  224. package/src/utils/application-history.ts +1 -0
  225. package/src/utils/cnpg-backup.test.ts +26 -0
  226. package/src/utils/cnpg-backup.ts +70 -0
  227. package/src/utils/format.test.ts +9 -0
  228. package/src/utils/format.ts +4 -0
  229. package/src/utils/gitops-owner.ts +3 -2
  230. package/src/utils/grant.test.ts +19 -0
  231. package/src/utils/grant.ts +35 -0
  232. package/src/utils/index.ts +4 -0
  233. package/src/utils/k8s-errors.ts +7 -7
  234. package/src/utils/log-format.test.ts +41 -0
  235. package/src/utils/log-format.ts +71 -15
  236. package/src/utils/log-level.ts +12 -8
  237. package/src/utils/navigation.test.ts +57 -0
  238. package/src/utils/navigation.ts +86 -8
  239. package/src/utils/object-reference-navigation.test.ts +43 -0
  240. package/src/utils/resource-hierarchy.test.ts +37 -0
  241. package/src/utils/resource-hierarchy.ts +15 -7
  242. package/src/utils/resource-icons.ts +2 -0
  243. package/src/utils/skeleton-yaml.test.ts +21 -0
  244. package/src/utils/skeleton-yaml.ts +16 -0
  245. package/src/utils/url-path.test.ts +35 -0
  246. package/src/utils/url-path.ts +21 -0
  247. package/src/utils/validators.test.ts +4 -13
  248. package/src/utils/validators.ts +5 -19
  249. package/src/utils/view-transition.test.ts +35 -1
  250. package/src/utils/view-transition.ts +16 -1
@@ -16,6 +16,17 @@ export interface LogStreamHandlers {
16
16
  onEnd?: (data: unknown) => void
17
17
  }
18
18
 
19
+ function errorCodeOf(event: Event): string | null {
20
+ if (!(event instanceof MessageEvent) || typeof event.data !== 'string') return null
21
+ try {
22
+ const data: unknown = JSON.parse(event.data)
23
+ if (typeof data === 'object' && data !== null && 'error_code' in data && typeof data.error_code === 'string') return data.error_code
24
+ } catch {
25
+ // Not JSON: no code.
26
+ }
27
+ return null
28
+ }
29
+
19
30
  /**
20
31
  * Manages an SSE log stream: EventSource lifecycle, isStreaming state, cleanup.
21
32
  * Callers provide a factory function that creates the EventSource with current params.
@@ -24,6 +35,8 @@ export function useLogStream() {
24
35
  const [isStreaming, setIsStreaming] = useState(false)
25
36
  // Set when the connection fails (and not on a clean end — see endedRef).
26
37
  const [streamError, setStreamError] = useState<string | null>(null)
38
+ // The server's error_code for that failure, when it sent one.
39
+ const [streamErrorCode, setStreamErrorCode] = useState<string | null>(null)
27
40
  // True from a start attempt until the stream first settles (connected / end /
28
41
  // error / stop). Lets callers show a connecting spinner that won't reappear
29
42
  // after a clean end. Starts true so an auto-stream viewer paints the spinner
@@ -40,6 +53,7 @@ export function useLogStream() {
40
53
  setIsStreaming(false)
41
54
  setConnecting(false)
42
55
  setStreamError(null)
56
+ setStreamErrorCode(null)
43
57
  }, [])
44
58
 
45
59
  const startStreaming = useCallback((
@@ -50,6 +64,7 @@ export function useLogStream() {
50
64
  eventSourceRef.current?.close()
51
65
  endedRef.current = false
52
66
  setStreamError(null)
67
+ setStreamErrorCode(null)
53
68
  setConnecting(true)
54
69
  const es = create()
55
70
  // Ignore events from a superseded source: closing/replacing an EventSource
@@ -124,6 +139,7 @@ export function useLogStream() {
124
139
  // 'end'; that's not a failure, so don't log it or surface it.
125
140
  if (endedRef.current) return
126
141
  setStreamError(handleSSEError(event, errorContext, () => {}))
142
+ setStreamErrorCode(errorCodeOf(event))
127
143
  })
128
144
 
129
145
  eventSourceRef.current = es
@@ -132,5 +148,5 @@ export function useLogStream() {
132
148
  // Cleanup on unmount
133
149
  useEffect(() => () => { eventSourceRef.current?.close() }, [])
134
150
 
135
- return { isStreaming, streamError, connecting, startStreaming, stopStreaming }
151
+ return { isStreaming, streamError, streamErrorCode, connecting, startStreaming, stopStreaming }
136
152
  }
@@ -0,0 +1 @@
1
+ export * from './problems'
@@ -0,0 +1,63 @@
1
+ import { describe, expect, expectTypeOf, it } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { ProblemMeta, WorkspaceProblemCallout, ProblemList, problemOriginLabel, type WorkspaceProblem } from './problems'
4
+
5
+ const details = (kind: string) => ({
6
+ id: 'p',
7
+ tone: 'degraded' as const,
8
+ category: 'availability',
9
+ title: 'Something needs a look',
10
+ subject: { kind, group: 'example.io', namespace: 'ns', name: 'child-1' },
11
+ })
12
+ const problem = (kind: string): Extract<WorkspaceProblem, { source: 'issue' }> => ({ ...details(kind), source: 'issue' })
13
+
14
+ describe('ProblemMeta', () => {
15
+ it('names the subject only when it is not the workspace root kind', () => {
16
+ expect(renderToStaticMarkup(<ProblemMeta problem={problem('Backup')} rootKind="Cluster" />)).toContain('Backup')
17
+ expect(renderToStaticMarkup(<ProblemMeta problem={problem('Cluster')} rootKind="Cluster" />)).not.toContain('child-1')
18
+ })
19
+ })
20
+
21
+ describe('problemOriginLabel', () => {
22
+ it('says who measured a measurement, and never calls an issue generic', () => {
23
+ expect(problemOriginLabel({ ...details('Cluster'), source: 'measurement', measuredBy: 'Prometheus' }).label).toBe('Measured by Prometheus')
24
+ expect(problemOriginLabel(problem('Cluster')).label).toBe('Detected by Radar')
25
+ })
26
+ })
27
+
28
+ it('keeps original scheduler evidence behind a closed disclosure in callouts and lists', () => {
29
+ const p = { ...problem('Pod'), detail: 'both nodes have reached their Pod limit', rawDetailLabel: 'Scheduler message', rawDetail: '0/2 nodes are available: 2 Too many pods.' }
30
+ for (const node of [<WorkspaceProblemCallout problem={p} rootKind="Cluster" />, <ProblemList problems={[p]} rootKind="Cluster" />]) {
31
+ const html = renderToStaticMarkup(node)
32
+ expect(html).toContain(p.detail)
33
+ expect(html).toContain(p.rawDetail)
34
+ expect(html).toContain('Scheduler message')
35
+ expect(html).toContain('aria-expanded="false"')
36
+ }
37
+ expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={problem('Pod')} rootKind="Cluster" />)).not.toContain('Scheduler message')
38
+ })
39
+
40
+ it('uses a generic label for raw evidence from another controller', () => {
41
+ const p = { ...problem('Deployment'), rawDetail: 'Progress deadline exceeded.' }
42
+ expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Application" />)).toContain('Original evidence')
43
+ })
44
+
45
+ it('keeps source-specific provenance fields exclusive in the public type', () => {
46
+ type Details = ReturnType<typeof details>
47
+ expectTypeOf<Details & { source: 'issue'; measuredBy: string }>().not.toMatchTypeOf<WorkspaceProblem>()
48
+ expectTypeOf<Details & { source: 'measurement'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
49
+ expectTypeOf<Details & { source: 'audit'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
50
+ })
51
+
52
+ it('preserves alert and unknown tones in callouts and compact lists', () => {
53
+ for (const tone of ['alert', 'unknown'] as const) {
54
+ const p = { ...problem('Pod'), tone }
55
+ const callout = renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Cluster" />)
56
+ expect(callout).toContain(`status-${tone}`)
57
+ const list = renderToStaticMarkup(<ProblemList problems={[p]} rootKind="Cluster" />)
58
+ expect(list).toContain(tone === 'alert' ? 'bg-orange-500' : 'bg-slate-400')
59
+ expect(list).toContain(tone === 'alert' ? 'text-orange-600' : 'text-theme-text-tertiary')
60
+ }
61
+ type HealthyProblem = Omit<ReturnType<typeof details>, 'tone'> & { source: 'issue'; tone: 'healthy' }
62
+ expectTypeOf<HealthyProblem>().not.toMatchTypeOf<WorkspaceProblem>()
63
+ })
@@ -0,0 +1,165 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import type { HealthLevel } from '../resources/resource-utils'
4
+ import type { ResourceRef } from '../../types/core'
5
+ import { healthToSeverity } from '../../utils/badge-colors'
6
+ import { StatusDot, toneTextClass } from '../ui/status-tone'
7
+ import { Tooltip } from '../ui/Tooltip'
8
+ import { AlertBanner } from '../ui/drawer-components'
9
+ import { FoldSection } from '../ui/FoldSection'
10
+ import { RefLink, type NavigateToRef } from '../ui/RefLink'
11
+
12
+ /** Where a problem's evidence comes from, in user terms. */
13
+ export interface ProblemOrigin {
14
+ label: string
15
+ /** The exact field or condition, shown on hover. */
16
+ detail?: string
17
+ }
18
+
19
+ /**
20
+ * Something about a workspace object that needs a look. `C` is the
21
+ * integration's own category set.
22
+ */
23
+ export interface ProblemDetails<C extends string = string> {
24
+ /** Stable identity for keys. */
25
+ id: string
26
+ category: C
27
+ title: string
28
+ detail?: string
29
+ rawDetail?: string
30
+ rawDetailLabel?: string
31
+ /** The object the evidence is about: the workspace's root object or one of its children. */
32
+ subject: ResourceRef & { group: string }
33
+ /** A shorter headline for tight places (a fleet cell); `title` stays the precise one. */
34
+ shortTitle?: string
35
+ /** Other objects the same problem is about, e.g. earlier runs that failed the same way. */
36
+ alsoAbout?: Pick<ResourceRef, 'kind' | 'name'>[]
37
+ }
38
+
39
+ export type ProblemProvenance =
40
+ | { source: 'issue'; origin?: ProblemOrigin; measuredBy?: never; sourceDetail?: never }
41
+ | { source: 'audit'; sourceDetail?: string; origin?: never; measuredBy?: never }
42
+ | { source: 'measurement'; measuredBy?: string; sourceDetail?: string; origin?: never }
43
+
44
+ export type WorkspaceProblem<C extends string = string> = ProblemDetails<C> & ProblemProvenance & { tone: Exclude<HealthLevel, 'healthy'> }
45
+
46
+ /** A problem's provenance label: where its evidence comes from, never a generic "Radar issue". */
47
+ export function problemOriginLabel(problem: WorkspaceProblem): ProblemOrigin {
48
+ switch (problem.source) {
49
+ case 'audit':
50
+ return { label: 'Best-practice check', detail: problem.sourceDetail }
51
+ case 'measurement':
52
+ return { label: problem.measuredBy ? `Measured by ${problem.measuredBy}` : 'Measured', detail: problem.sourceDetail }
53
+ }
54
+ return problem.origin ?? { label: 'Detected by Radar' }
55
+ }
56
+
57
+ /**
58
+ * How a host opens a problem on its Issues page. Supplied by context so every
59
+ * summary and drawer gets the link without threading a prop through each.
60
+ */
61
+ export const OpenIssueContext = createContext<((problem: WorkspaceProblem) => void) | undefined>(undefined)
62
+
63
+ export function ProblemMeta({
64
+ problem,
65
+ rootKind,
66
+ onNavigate,
67
+ subjectIsSelf,
68
+ children,
69
+ }: {
70
+ problem: WorkspaceProblem
71
+ /** The workspace's root kind: a problem about another kind names its subject. */
72
+ rootKind: string
73
+ onNavigate?: NavigateToRef
74
+ subjectIsSelf?: boolean
75
+ children?: ReactNode
76
+ }) {
77
+ const openIssue = useContext(OpenIssueContext)
78
+ const origin = problemOriginLabel(problem)
79
+ const aboutChild = !subjectIsSelf && problem.subject.kind !== rootKind
80
+ return (
81
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
82
+ {aboutChild && (
83
+ <span>
84
+ {problem.subject.kind}{' '}
85
+ <RefLink refTo={problem.subject} onNavigate={onNavigate} mono />
86
+ {problem.alsoAbout && problem.alsoAbout.length > 0 && ' '}
87
+ {problem.alsoAbout && problem.alsoAbout.length > 0 && (
88
+ <Tooltip
89
+ content={
90
+ <ul>
91
+ {problem.alsoAbout.map((o) => (
92
+ <li key={`${o.kind}/${o.name}`} className="font-mono">
93
+ {o.kind} {o.name}
94
+ </li>
95
+ ))}
96
+ </ul>
97
+ }
98
+ >
99
+ <span>and {problem.alsoAbout.length} more</span>
100
+ </Tooltip>
101
+ )}
102
+ </span>
103
+ )}
104
+ <Tooltip content={origin.detail} disabled={!origin.detail}>
105
+ <span>{origin.label}</span>
106
+ </Tooltip>
107
+ {openIssue && problem.source === 'issue' && (
108
+ <button type="button" onClick={() => openIssue(problem)} className="whitespace-nowrap text-accent-text hover:underline">
109
+ See in Issues →
110
+ </button>
111
+ )}
112
+ {children}
113
+ </div>
114
+ )
115
+ }
116
+
117
+ export function WorkspaceProblemCallout({
118
+ problem,
119
+ rootKind,
120
+ more,
121
+ onNavigate,
122
+ action,
123
+ subjectIsSelf,
124
+ }: {
125
+ problem: WorkspaceProblem
126
+ rootKind: string
127
+ more?: ReactNode
128
+ onNavigate?: NavigateToRef
129
+ action?: ReactNode
130
+ /** The callout sits on the subject's own page, so linking to it would loop. */
131
+ subjectIsSelf?: boolean
132
+ }) {
133
+ const severity = healthToSeverity(problem.tone)
134
+ const variant = severity === 'neutral' ? 'unknown' : severity
135
+ return (
136
+ <AlertBanner variant={variant} title={problem.title} message={problem.detail}>
137
+ {problem.rawDetail && <FoldSection title={problem.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{problem.rawDetail}</div></FoldSection>}
138
+ <ProblemMeta problem={problem} rootKind={rootKind} onNavigate={onNavigate} subjectIsSelf={subjectIsSelf}>
139
+ {action}
140
+ {more}
141
+ </ProblemMeta>
142
+ </AlertBanner>
143
+ )
144
+ }
145
+
146
+ /** The problems a callout does not show, as a compact list with the callout's tone, title and source. */
147
+ export function ProblemList({ problems, rootKind, onNavigate }: { problems: WorkspaceProblem[]; rootKind: string; onNavigate?: NavigateToRef }) {
148
+ return (
149
+ <ul className="space-y-2">
150
+ {problems.map((p) => (
151
+ <li key={p.id} className="flex items-start gap-2 text-sm">
152
+ <span className="mt-1.5 shrink-0">
153
+ <StatusDot tone={p.tone} size="sm" />
154
+ </span>
155
+ <div className="min-w-0">
156
+ <div className={clsx('font-medium break-words', toneTextClass(p.tone))}>{p.title}</div>
157
+ {p.detail && <div className="text-xs text-theme-text-secondary break-words">{p.detail}</div>}
158
+ {p.rawDetail && <FoldSection title={p.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{p.rawDetail}</div></FoldSection>}
159
+ <ProblemMeta problem={p} rootKind={rootKind} onNavigate={onNavigate} />
160
+ </div>
161
+ </li>
162
+ ))}
163
+ </ul>
164
+ )
165
+ }
@@ -0,0 +1,78 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { ActiveFilterBar, type ActiveFilterBarProps } from './ActiveFilterBar'
6
+
7
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
8
+
9
+ let root: Root | null = null
10
+ let element: HTMLDivElement
11
+
12
+ async function render(overrides: Partial<ActiveFilterBarProps> = {}) {
13
+ const props: ActiveFilterBarProps = {
14
+ columnFilters: {},
15
+ columnFilterExcludes: {},
16
+ problemFilters: [],
17
+ labelSelector: '',
18
+ onClearColumn: vi.fn(),
19
+ onClearProblems: vi.fn(),
20
+ onClearLabels: vi.fn(),
21
+ ...overrides,
22
+ }
23
+ element = document.createElement('div')
24
+ root = createRoot(element)
25
+ await act(async () => root!.render(<ActiveFilterBar {...props} />))
26
+ return props
27
+ }
28
+
29
+ function buttonWithText(text: string): HTMLButtonElement {
30
+ const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(text))
31
+ if (!button) throw new Error(`no button containing "${text}"`)
32
+ return button
33
+ }
34
+
35
+ afterEach(async () => {
36
+ await act(async () => root?.unmount())
37
+ root = null
38
+ })
39
+
40
+ describe('ActiveFilterBar', () => {
41
+ it('renders nothing without active filters or a restore', async () => {
42
+ await render({ columnFilters: { status: [] } })
43
+ expect(element.innerHTML).toBe('')
44
+ })
45
+
46
+ it('renders one chip per active filter, marking excluded columns', async () => {
47
+ await render({
48
+ columnFilters: { status: ['Running', 'Pending'], node: ['n1'], empty: [] },
49
+ columnFilterExcludes: { node: true },
50
+ problemFilters: ['crashloop'],
51
+ labelSelector: 'app=nginx',
52
+ })
53
+ expect(element.querySelectorAll('button')).toHaveLength(4)
54
+ expect(element.textContent).toContain('status: Running, Pending')
55
+ expect(element.textContent).toContain('node: not n1')
56
+ expect(element.textContent).toContain('Problems: crashloop')
57
+ expect(element.textContent).toContain('app=nginx')
58
+ expect(element.textContent).not.toContain('restored')
59
+ })
60
+
61
+ it('calls the matching clear handler when a chip is clicked', async () => {
62
+ const props = await render({ columnFilters: { status: ['Running'] }, problemFilters: ['oom'], labelSelector: 'tier=web' })
63
+ await act(async () => buttonWithText('status:').click())
64
+ await act(async () => buttonWithText('Problems:').click())
65
+ await act(async () => buttonWithText('tier=web').click())
66
+ expect(props.onClearColumn).toHaveBeenCalledWith('status')
67
+ expect(props.onClearProblems).toHaveBeenCalledOnce()
68
+ expect(props.onClearLabels).toHaveBeenCalledOnce()
69
+ })
70
+
71
+ it('shows the restored line only when restored, with Clear calling onClearAll', async () => {
72
+ const onClearAll = vi.fn()
73
+ await render({ restored: true, onClearAll, columnFilters: { status: ['Running'] } })
74
+ expect(element.textContent).toContain('Filters restored from your last visit')
75
+ await act(async () => buttonWithText('Clear').click())
76
+ expect(onClearAll).toHaveBeenCalledOnce()
77
+ })
78
+ })
@@ -0,0 +1,86 @@
1
+ import { clsx } from 'clsx'
2
+ import { AlertTriangle, History, ListFilter, Tag, X } from 'lucide-react'
3
+
4
+ export interface ActiveFilterBarProps {
5
+ columnFilters: Record<string, string[]>
6
+ columnFilterExcludes: Record<string, boolean>
7
+ problemFilters: string[]
8
+ labelSelector: string
9
+ onClearColumn: (key: string) => void
10
+ onClearProblems: () => void
11
+ onClearLabels: () => void
12
+ restored?: boolean
13
+ onClearAll?: () => void
14
+ className?: string
15
+ }
16
+
17
+ export function ActiveFilterBar({
18
+ columnFilters,
19
+ columnFilterExcludes,
20
+ problemFilters,
21
+ labelSelector,
22
+ onClearColumn,
23
+ onClearProblems,
24
+ onClearLabels,
25
+ restored = false,
26
+ onClearAll,
27
+ className,
28
+ }: ActiveFilterBarProps) {
29
+ const activeColEntries = Object.entries(columnFilters).filter(([, vals]) => vals.length > 0)
30
+ const hasChips = activeColEntries.length > 0 || problemFilters.length > 0 || !!labelSelector
31
+ if (!hasChips && !restored) return null
32
+
33
+ return (
34
+ <div className={clsx('flex flex-wrap items-center gap-1.5', className)}>
35
+ {restored && (
36
+ <span className="flex items-center gap-1 text-xs text-theme-text-tertiary">
37
+ <History className="w-3 h-3" />
38
+ <span>Filters restored from your last visit.</span>
39
+ {onClearAll && (
40
+ <button
41
+ type="button"
42
+ onClick={onClearAll}
43
+ className="text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2"
44
+ >
45
+ Clear
46
+ </button>
47
+ )}
48
+ </span>
49
+ )}
50
+ {activeColEntries.map(([key, vals]) => (
51
+ <button
52
+ type="button"
53
+ key={key}
54
+ onClick={() => onClearColumn(key)}
55
+ className="flex items-center gap-1 px-2 py-1 text-xs selection selection-text rounded-md hover:selection-strong transition-colors"
56
+ >
57
+ <ListFilter className="w-3 h-3" />
58
+ <span>{key}: {columnFilterExcludes[key] ? 'not ' : ''}{vals.join(', ')}</span>
59
+ <X className="w-3 h-3" />
60
+ </button>
61
+ ))}
62
+ {problemFilters.length > 0 && (
63
+ <button
64
+ type="button"
65
+ onClick={onClearProblems}
66
+ className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/15 text-red-700 dark:text-red-300 rounded-md hover:bg-red-500/25 transition-colors"
67
+ >
68
+ <AlertTriangle className="w-3 h-3" />
69
+ <span>Problems: {problemFilters.join(', ')}</span>
70
+ <X className="w-3 h-3" />
71
+ </button>
72
+ )}
73
+ {labelSelector && (
74
+ <button
75
+ type="button"
76
+ onClick={onClearLabels}
77
+ className="flex items-center gap-1 px-2 py-1 text-xs bg-green-500/15 text-green-700 dark:text-green-300 rounded-md hover:bg-green-500/25 transition-colors"
78
+ >
79
+ <Tag className="w-3 h-3" />
80
+ <span>{labelSelector}</span>
81
+ <X className="w-3 h-3" />
82
+ </button>
83
+ )}
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,46 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { ColumnPickerList } from './ColumnPickerList'
6
+
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+
9
+ describe('ColumnPickerList keyboard focus', () => {
10
+ it.each([
11
+ ['namespace', 'ArrowUp'],
12
+ ['age', 'ArrowDown'],
13
+ ])('does not restore stale focus after repeated boundary moves on %s', (key, direction) => {
14
+ const host = document.createElement('div')
15
+ document.body.append(host)
16
+ const root = createRoot(host)
17
+ const onReorder = vi.fn()
18
+ const columns = ['name', 'namespace', 'age'].map(key => ({ key, label: key }))
19
+ const render = () => act(() => root.render(<ColumnPickerList
20
+ columns={columns}
21
+ visibleColumns={new Set(['name', 'namespace', 'age'])}
22
+ customColumnKeys={new Set()}
23
+ onToggle={() => {}}
24
+ onRemoveCustom={() => {}}
25
+ onReorder={onReorder}
26
+ />))
27
+ try {
28
+ render()
29
+ const inputs = [...host.querySelectorAll<HTMLInputElement>('input')]
30
+ const current = inputs[columns.findIndex(column => column.key === key)]
31
+ const other = inputs[key === 'namespace' ? 2 : 1]
32
+ if (!current || !other) throw new Error('Expected both movable checkboxes')
33
+ current.focus()
34
+ for (let i = 0; i < 3; i++) {
35
+ act(() => current.dispatchEvent(new KeyboardEvent('keydown', { key: direction, altKey: true, bubbles: true })))
36
+ }
37
+ other.focus()
38
+ render()
39
+ expect(document.activeElement).toBe(other)
40
+ expect(onReorder).not.toHaveBeenCalled()
41
+ } finally {
42
+ act(() => root.unmount())
43
+ host.remove()
44
+ }
45
+ })
46
+ })
@@ -0,0 +1,174 @@
1
+ import { useId, useLayoutEffect, useRef, useState } from 'react'
2
+ import type React from 'react'
3
+ import { GripVertical, X } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+ import { Tooltip } from '../ui/Tooltip'
6
+ import { isMac } from '../../utils/platform'
7
+ import { moveColumn, pinnedColumnCount } from './column-order'
8
+ import type { Column } from './ResourcesView'
9
+
10
+ interface ColumnPickerListProps {
11
+ /** Every offered column, in display order. */
12
+ columns: Column[]
13
+ visibleColumns: Set<string>
14
+ customColumnKeys: Set<string>
15
+ onToggle: (key: string) => void
16
+ onRemoveCustom: (key: string) => void
17
+ /** Receives the movable column keys in their new order. */
18
+ onReorder: (order: string[]) => void
19
+ }
20
+
21
+ /** The Columns picker's rows: show/hide, remove a custom column, and reorder by
22
+ * dragging the grip or with Alt/Option+Up/Down on the row's checkbox. */
23
+ export function ColumnPickerList({
24
+ columns,
25
+ visibleColumns,
26
+ customColumnKeys,
27
+ onToggle,
28
+ onRemoveCustom,
29
+ onReorder,
30
+ }: ColumnPickerListProps) {
31
+ const pinned = pinnedColumnCount(columns)
32
+ const [dragKey, setDragKey] = useState<string | null>(null)
33
+ // Insertion point among the movable rows: 0 = before the first one.
34
+ const [dropGap, setDropGap] = useState<number | null>(null)
35
+ const [announcement, setAnnouncement] = useState('')
36
+ const hintId = useId()
37
+ const mac = isMac()
38
+
39
+ // React moves a keyed row by re-inserting DOM nodes, and re-inserting the
40
+ // focused checkbox drops focus. Put it back so repeated presses keep working.
41
+ const refocus = useRef<HTMLElement | null>(null)
42
+ useLayoutEffect(() => {
43
+ const el = refocus.current
44
+ if (!el) return
45
+ refocus.current = null
46
+ if (el.isConnected && document.activeElement !== el) el.focus()
47
+ })
48
+
49
+ const commitMove = (key: string, toIndex: number) => {
50
+ const next = moveColumn(columns, key, toIndex)
51
+ if (!next) return false
52
+ onReorder(next)
53
+ const col = columns.find(c => c.key === key)
54
+ setAnnouncement(`${col?.label ?? key} moved to position ${pinned + next.indexOf(key) + 1} of ${columns.length}.`)
55
+ return true
56
+ }
57
+
58
+ const handleKeyDown = (e: React.KeyboardEvent, key: string, movableIndex: number) => {
59
+ if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return
60
+ e.preventDefault()
61
+ e.stopPropagation()
62
+ const up = e.key === 'ArrowUp'
63
+ refocus.current = e.target instanceof HTMLElement ? e.target : null
64
+ if (!commitMove(key, movableIndex + (up ? -1 : 1))) {
65
+ refocus.current = null
66
+ const label = columns.find(c => c.key === key)?.label ?? key
67
+ setAnnouncement(up ? `${label} is already at the top.` : `${label} is already at the bottom.`)
68
+ }
69
+ }
70
+
71
+ // The gap above the row when the pointer is in its top half, else below it.
72
+ const gapAt = (e: React.DragEvent, movableIndex: number) => {
73
+ const rect = e.currentTarget.getBoundingClientRect()
74
+ return e.clientY < rect.top + rect.height / 2 ? movableIndex : movableIndex + 1
75
+ }
76
+
77
+ const endDrag = () => {
78
+ setDragKey(null)
79
+ setDropGap(null)
80
+ }
81
+
82
+ return (
83
+ <>
84
+ <span id={hintId} className="sr-only">
85
+ {mac ? 'Press Option+Up Arrow or Option+Down Arrow to move this column.' : 'Press Alt+Up Arrow or Alt+Down Arrow to move this column.'}
86
+ </span>
87
+ <span className="sr-only" role="status" aria-live="polite">{announcement}</span>
88
+ {columns.map((col, i) => {
89
+ const isCustom = customColumnKeys.has(col.key)
90
+ const movableIndex = i - pinned
91
+ const movable = movableIndex >= 0
92
+ const fromIndex = dragKey ? columns.findIndex(c => c.key === dragKey) - pinned : -1
93
+ const showGap = (gap: number) => dropGap === gap && gap !== fromIndex && gap !== fromIndex + 1
94
+ return (
95
+ <div
96
+ key={col.key}
97
+ data-column-row
98
+ className={clsx(
99
+ 'group relative flex items-center gap-1.5 pl-1.5 pr-3 py-1.5 hover:bg-theme-elevated',
100
+ dragKey === col.key && 'opacity-50',
101
+ )}
102
+ onKeyDown={movable ? e => handleKeyDown(e, col.key, movableIndex) : undefined}
103
+ onDragOver={movable && dragKey ? e => {
104
+ e.preventDefault()
105
+ e.dataTransfer.dropEffect = 'move'
106
+ setDropGap(gapAt(e, movableIndex))
107
+ } : undefined}
108
+ onDrop={movable && dragKey ? e => {
109
+ e.preventDefault()
110
+ const gap = gapAt(e, movableIndex)
111
+ commitMove(dragKey, gap > fromIndex ? gap - 1 : gap)
112
+ endDrag()
113
+ } : undefined}
114
+ >
115
+ {movable && showGap(movableIndex) && (
116
+ <span aria-hidden className="pointer-events-none absolute inset-x-2 -top-px h-0.5 rounded-full bg-accent" />
117
+ )}
118
+ {movable && showGap(movableIndex + 1) && i === columns.length - 1 && (
119
+ <span aria-hidden className="pointer-events-none absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
120
+ )}
121
+ {movable ? (
122
+ <Tooltip content={mac ? 'Drag to reorder, or press ⌥↑ / ⌥↓' : 'Drag to reorder, or press Alt+↑ / Alt+↓'} position="left">
123
+ <span
124
+ aria-hidden
125
+ draggable
126
+ onDragStart={e => {
127
+ e.dataTransfer.effectAllowed = 'move'
128
+ // Firefox starts a drag only when data is set.
129
+ e.dataTransfer.setData('text/plain', col.key)
130
+ const row = e.currentTarget.closest('[data-column-row]')
131
+ if (row instanceof HTMLElement) e.dataTransfer.setDragImage(row, 12, row.offsetHeight / 2)
132
+ setDragKey(col.key)
133
+ }}
134
+ onDragEnd={endDrag}
135
+ className="flex shrink-0 cursor-grab items-center text-theme-text-tertiary opacity-60 group-hover:opacity-100 active:cursor-grabbing"
136
+ >
137
+ <GripVertical className="w-3.5 h-3.5" />
138
+ </span>
139
+ </Tooltip>
140
+ ) : (
141
+ <span aria-hidden className="w-3.5 shrink-0" />
142
+ )}
143
+ <label className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
144
+ <input
145
+ type="checkbox"
146
+ checked={visibleColumns.has(col.key)}
147
+ onChange={() => onToggle(col.key)}
148
+ disabled={col.key === 'name'}
149
+ aria-describedby={movable ? hintId : undefined}
150
+ className="rounded border-theme-border"
151
+ />
152
+ <span className={clsx(
153
+ 'text-sm truncate',
154
+ col.key === 'name' ? 'text-theme-text-tertiary' : 'text-theme-text-primary'
155
+ )} title={col.tooltip || col.label}>
156
+ {col.label}
157
+ </span>
158
+ </label>
159
+ {isCustom && (
160
+ <button
161
+ type="button"
162
+ onClick={() => onRemoveCustom(col.key)}
163
+ className="shrink-0 p-0.5 text-theme-text-tertiary hover:text-red-500 opacity-0 group-hover:opacity-100 focus:opacity-100"
164
+ title="Remove column"
165
+ >
166
+ <X className="w-3.5 h-3.5" />
167
+ </button>
168
+ )}
169
+ </div>
170
+ )
171
+ })}
172
+ </>
173
+ )
174
+ }