@skyhook-io/radar-app 0.2.2 → 0.3.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 (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,7 +1,8 @@
1
- import { useDashboard, useDashboardCRDs, useDashboardHelm } from '../../api/client'
2
- import type { DashboardResponse } from '../../api/client'
1
+ import { useMemo, type ReactNode } from 'react'
2
+ import { useDashboard, useDashboardCRDs, useDashboardHelm, useIssues, type IssuesResponse } from '../../api/client'
3
+ import { useConnection } from '../../context/ConnectionContext'
4
+ import type { ClusterLoadState } from '../../types/clusterLoadState'
3
5
  import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
4
- import { kindToPlural } from '../../utils/navigation'
5
6
  import { TopologyPreview } from './TopologyPreview'
6
7
  import { HelmSummary } from './HelmSummary'
7
8
  import { ActivitySummary } from './ActivitySummary'
@@ -9,9 +10,22 @@ import { TrafficSummary } from './TrafficSummary'
9
10
  import { CertificateHealthCard } from './CertificateHealthCard'
10
11
  import { NetworkPolicyCoverageCard } from './NetworkPolicyCoverageCard'
11
12
  import { CostCard } from './CostCard'
12
- import { AuditCard, PaneLoader, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui'
13
+ import { GitOpsControllersCard } from './GitOpsControllersCard'
14
+ import { Tooltip } from '../ui/Tooltip'
15
+ import {
16
+ AuditCard,
17
+ FreshnessControl,
18
+ PaneLoader,
19
+ StatusDot,
20
+ categoryLabel,
21
+ groupLabel,
22
+ issueTiming,
23
+ subjectRef,
24
+ type Issue,
25
+ } from '@skyhook-io/k8s-ui'
26
+ import { formatCompactAge } from '@skyhook-io/k8s-ui/utils/format'
13
27
  import { ClusterHealthCard } from './ClusterHealthCard'
14
- import { AlertTriangle, Shield } from 'lucide-react'
28
+ import { AlertTriangle, CheckCircle, Loader2, Shield } from 'lucide-react'
15
29
  import { clsx } from 'clsx'
16
30
 
17
31
  interface HomeViewProps {
@@ -20,16 +34,44 @@ interface HomeViewProps {
20
34
  onNavigateToView: (view: ExtendedMainView, params?: Record<string, string>) => void
21
35
  onNavigateToResourceKind: (kind: string, group?: string, filters?: Record<string, string[]>) => void
22
36
  onNavigateToResource: (resource: SelectedResource) => void
37
+ fallbackClusterLoadState?: ClusterLoadState
38
+ /**
39
+ * Optional override for the Certificate Health card's click. When an embedded
40
+ * host (Radar Cloud) takes Certs over with its own fleet page, it passes this
41
+ * to route there instead of Radar's TLS-secrets resource list. Omitted in
42
+ * standalone OSS → the card drills into secrets as before.
43
+ */
44
+ onNavigateToCerts?: () => void
23
45
  }
24
46
 
25
- export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource }: HomeViewProps) {
26
- const { data, isLoading, error } = useDashboard(namespaces)
47
+ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts }: HomeViewProps) {
48
+ const { data, isLoading, error, dataUpdatedAt, refetch } = useDashboard(namespaces)
49
+ const { connection } = useConnection()
50
+ const { data: issuesData, isLoading: issuesLoading, isFetching: issuesFetching, error: issuesError } = useIssues(namespaces)
51
+ const issues = issuesData?.issues ?? []
52
+ const issueCount = issuesData?.total_matched ?? issuesData?.total ?? issues.length
53
+ const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
54
+
55
+ // SSE is cluster-wide on small/medium clusters; the picker only narrows the
56
+ // dashboard summary, so re-apply the filter here or the legend disagrees.
57
+ const scopedTopology = useMemo<Topology | null>(() => {
58
+ if (!topology) return null
59
+ if (namespaces.length === 0) return topology
60
+ const nsSet = new Set(namespaces)
61
+ const nodes = topology.nodes.filter(n => {
62
+ const ns = n.data.namespace as string | undefined
63
+ return !ns || nsSet.has(ns)
64
+ })
65
+ const nodeIds = new Set(nodes.map(n => n.id))
66
+ const edges = topology.edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
67
+ return { nodes, edges }
68
+ }, [topology, namespaces])
27
69
  // CRDs and Helm load lazily after main dashboard to keep initial load fast
28
70
  const { data: crdsData } = useDashboardCRDs(namespaces)
29
71
  const { data: helmData } = useDashboardHelm(namespaces)
30
72
 
31
73
  if (isLoading) {
32
- return <PaneLoader label="Loading dashboard…" className="flex-1" />
74
+ return <PaneLoader label="Loading dashboard…" className="flex-1 min-h-0" />
33
75
  }
34
76
 
35
77
  if (error || !data) {
@@ -56,159 +98,351 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
56
98
  )
57
99
  }
58
100
 
59
- const hasProblems = data.problems && data.problems.length > 0
60
-
61
101
  return (
62
102
  <div className="flex-1 overflow-y-auto">
63
103
  <div className="max-w-[1600px] mx-auto px-6 py-6 space-y-6">
104
+ {fallbackClusterLoadState?.loading && (
105
+ <div className="flex items-center gap-2 text-sm text-theme-text-tertiary">
106
+ <Loader2 className="h-4 w-4 animate-spin" />
107
+ <span>{fallbackClusterLoadState.message}</span>
108
+ </div>
109
+ )}
64
110
  {/* Row 1: Cluster Health Card (combined health + resource counts) */}
65
111
  <ClusterHealthCard
112
+ freshness={
113
+ <FreshnessControl
114
+ mode="auto"
115
+ dataUpdatedAt={dataUpdatedAt}
116
+ onRefresh={() => refetch()}
117
+ connectionState={connection.state}
118
+ />
119
+ }
66
120
  health={data.health}
67
121
  counts={data.resourceCounts}
68
122
  cluster={data.cluster}
69
123
  metrics={data.metrics}
70
124
  metricsServerAvailable={data.metricsServerAvailable}
71
125
  topCRDs={crdsData?.topCRDs}
72
- problems={data.problems ?? []}
126
+ issueCount={issueCount}
127
+ hasCriticalIssues={hasCriticalIssues}
73
128
  nodeVersionSkew={data.nodeVersionSkew}
74
129
  onNavigateToKind={onNavigateToResourceKind}
75
130
  onNavigateToView={() => onNavigateToView('resources')}
76
131
  onWarningEventsClick={() => onNavigateToView('timeline', { view: 'list', filter: 'warnings', time: 'all' })}
77
- onUnhealthyClick={() => onNavigateToView('timeline', { view: 'list', filter: 'unhealthy', time: 'all' })}
132
+ onIssuesClick={() => onNavigateToView('issues')}
78
133
  />
79
134
 
80
- {/* Row 2: Main content columns teasers left, problems right (if any) */}
81
- <div className={clsx(
82
- 'grid gap-6',
83
- hasProblems ? 'grid-cols-1 lg:grid-cols-[1fr_420px]' : 'grid-cols-1'
84
- )}>
135
+ {/* Row 2: Main content columns - teasers left, issues right */}
136
+ <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_420px]">
85
137
  {/* Left column: teaser cards */}
86
138
  <div className="flex flex-col gap-6 auto-rows-min">
87
- {/* Primary cards — 2-col grid */}
139
+ {/* Live bandTopology + Timeline always render, so a fixed 2-up never strands.
140
+ These are the richest visuals and the most-used live views, so they get the width. */}
88
141
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
89
142
  <TopologyPreview
90
- topology={topology}
143
+ topology={scopedTopology}
91
144
  summary={data.topologySummary}
92
145
  onNavigate={() => onNavigateToView('topology')}
93
146
  />
94
- <HelmSummary
95
- data={helmData}
96
- onNavigate={() => onNavigateToView('helm')}
97
- />
98
147
  <ActivitySummary
99
148
  namespaces={namespaces}
100
- topology={topology}
149
+ topology={scopedTopology}
101
150
  onNavigate={() => onNavigateToView('timeline')}
102
151
  />
103
- <TrafficSummary
104
- data={data.trafficSummary}
105
- onNavigate={() => onNavigateToView('traffic')}
106
- />
107
- <CostCard onNavigate={() => onNavigateToView('cost')} />
108
152
  </div>
109
153
 
110
- {/* Health & compliance cards 3-col when enough cards, 2-col fallback */}
111
- {(data.certificateHealth || data.networkPolicyCoverage || data.audit) && (() => {
112
- const healthCards = [
113
- data.certificateHealth && (
114
- <CertificateHealthCard
115
- key="certs"
116
- data={data.certificateHealth}
117
- onNavigate={() => onNavigateToResourceKind('secrets', undefined, { type: ['TLS'] })}
118
- />
119
- ),
120
- data.networkPolicyCoverage && (
121
- <NetworkPolicyCoverageCard
122
- key="netpol"
123
- data={data.networkPolicyCoverage}
124
- onNavigate={() => onNavigateToResourceKind('networkpolicies', 'networking.k8s.io')}
125
- />
126
- ),
127
- data.audit && (
128
- <AuditCard
129
- key="audit"
130
- data={data.audit}
131
- onNavigate={() => onNavigateToView('audit')}
132
- />
133
- ),
134
- ].filter(Boolean)
135
-
136
- return (
137
- <div className={clsx(
138
- 'grid gap-6',
139
- healthCards.length >= 3 ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1 sm:grid-cols-2'
140
- )}>
141
- {healthCards}
142
- </div>
143
- )
144
- })()}
154
+ {/* Explore bandflex-grow wrap so the row always fills. The conditional
155
+ Cost card self-hides via BandItem's empty:hidden when OpenCost is absent,
156
+ leaving Traffic + Helm to stretch rather than stranding an empty cell. */}
157
+ <div className="flex flex-wrap gap-6">
158
+ <BandItem>
159
+ <TrafficSummary
160
+ data={data.trafficSummary}
161
+ onNavigate={() => onNavigateToView('traffic')}
162
+ />
163
+ </BandItem>
164
+ <BandItem>
165
+ <HelmSummary
166
+ data={helmData}
167
+ onNavigate={() => onNavigateToView('helm')}
168
+ />
169
+ </BandItem>
170
+ <BandItem>
171
+ <CostCard onNavigate={() => onNavigateToView('cost')} />
172
+ </BandItem>
173
+ </div>
174
+
175
+ {/* Posture band — same flex-grow wrap so any subset of compliance cards
176
+ fills its row instead of stranding the last one (the old 3-col grid
177
+ left Cluster Audit alone with two empty cells beside it). */}
178
+ {(data.certificateHealth || data.networkPolicyCoverage || data.audit || data.gitopsControllers) && (
179
+ <div className="flex flex-wrap gap-6">
180
+ {data.certificateHealth && (
181
+ <BandItem>
182
+ <CertificateHealthCard
183
+ data={data.certificateHealth}
184
+ onNavigate={onNavigateToCerts ?? (() => onNavigateToResourceKind('secrets', undefined, { type: ['TLS'] }))}
185
+ />
186
+ </BandItem>
187
+ )}
188
+ {data.networkPolicyCoverage && (
189
+ <BandItem>
190
+ <NetworkPolicyCoverageCard
191
+ data={data.networkPolicyCoverage}
192
+ onNavigate={() => onNavigateToResourceKind('networkpolicies', 'networking.k8s.io')}
193
+ />
194
+ </BandItem>
195
+ )}
196
+ {data.gitopsControllers && (
197
+ <BandItem>
198
+ <GitOpsControllersCard
199
+ data={data.gitopsControllers}
200
+ onNavigate={() => onNavigateToView('gitops')}
201
+ />
202
+ </BandItem>
203
+ )}
204
+ {data.audit && (
205
+ <BandItem>
206
+ <AuditCard
207
+ data={data.audit}
208
+ onNavigate={() => onNavigateToView('checks')}
209
+ />
210
+ </BandItem>
211
+ )}
212
+ </div>
213
+ )}
145
214
  </div>
146
215
 
147
- {/* Right column: problems panel */}
148
- {hasProblems && (
149
- <ProblemsPanel
150
- problems={data.problems}
151
- onResourceClick={onNavigateToResource}
152
- />
153
- )}
216
+ <ProblemsPanel
217
+ issues={issues}
218
+ issueCount={issueCount}
219
+ visibility={issuesData?.visibility}
220
+ hasData={!!issuesData}
221
+ isLoading={issuesLoading && !issuesData}
222
+ isRefreshing={issuesFetching && !!issuesData}
223
+ error={issuesError}
224
+ totalReturned={issues.length}
225
+ onNavigateToIssues={() => onNavigateToView('issues')}
226
+ onResourceClick={onNavigateToResource}
227
+ />
154
228
  </div>
155
229
  </div>
156
230
  </div>
157
231
  )
158
232
  }
159
233
 
234
+ // A self-tiling flex item: grows to share the row, clamps to a sensible min
235
+ // width, and removes itself (empty:hidden) when its card renders null — so a
236
+ // data-gated card (e.g. Cost without OpenCost) can't leave a phantom column.
237
+ function BandItem({ children }: { children: ReactNode }) {
238
+ return <div className="flex-1 min-w-[260px] empty:hidden [&>*]:w-full">{children}</div>
239
+ }
240
+
160
241
  // ============================================================================
161
242
  // Problems Panel (right sidebar, scrollable)
162
243
  // ============================================================================
163
244
 
164
245
  interface ProblemsPanelProps {
165
- problems: DashboardResponse['problems']
246
+ issues: Issue[]
247
+ issueCount: number
248
+ visibility?: IssuesResponse['visibility']
249
+ hasData: boolean
250
+ isLoading: boolean
251
+ isRefreshing: boolean
252
+ error: unknown
253
+ totalReturned: number
254
+ onNavigateToIssues: () => void
166
255
  onResourceClick: (resource: SelectedResource) => void
167
256
  }
168
257
 
169
258
 
170
- function ProblemsPanel({ problems, onResourceClick }: ProblemsPanelProps) {
259
+ function ProblemsPanel({
260
+ issues,
261
+ issueCount,
262
+ visibility,
263
+ hasData,
264
+ isLoading,
265
+ isRefreshing,
266
+ error,
267
+ totalReturned,
268
+ onNavigateToIssues,
269
+ onResourceClick,
270
+ }: ProblemsPanelProps) {
271
+ const hasCriticalIssues = issues.some((issue) => issue.severity === 'critical')
272
+ const hasIssues = issueCount > 0
273
+ const hasHardError = !!error && !hasData
274
+ const hasLimitedVisibility = !!visibility?.impact
275
+ const isTruncated = issueCount > totalReturned
276
+ const titleClass = hasCriticalIssues
277
+ ? 'text-red-500'
278
+ : hasIssues || hasHardError || hasLimitedVisibility
279
+ ? 'text-amber-500'
280
+ : 'text-theme-text-secondary'
281
+ const countClass = hasHardError
282
+ ? 'status-unknown'
283
+ : hasCriticalIssues
284
+ ? 'status-unhealthy'
285
+ : hasIssues || hasLimitedVisibility
286
+ ? 'status-degraded'
287
+ : 'status-healthy'
288
+ const countLabel = isLoading ? '...' : hasHardError ? 'error' : String(issueCount)
289
+
171
290
  return (
172
291
  <div className="rounded-xl bg-theme-surface shadow-theme-sm flex flex-col lg:max-h-[calc(100vh-280px)] lg:sticky lg:top-0">
173
292
  <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50 shrink-0">
174
293
  <div className="flex items-center gap-2">
175
- <AlertTriangle className="w-4 h-4 text-red-500" />
176
- <span className="text-xs font-semibold uppercase tracking-wider text-red-500">Unhealthy Workloads</span>
294
+ <AlertTriangle className={clsx('w-4 h-4', titleClass)} />
295
+ <span className={clsx('text-xs font-semibold uppercase tracking-wider', titleClass)}>Active Issues</span>
296
+ </div>
297
+ <div className="flex items-center gap-2">
298
+ <button
299
+ type="button"
300
+ className="rounded-md px-2 py-1 text-xs font-medium text-accent-text transition-colors hover:bg-accent-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40"
301
+ onClick={onNavigateToIssues}
302
+ >
303
+ View all
304
+ </button>
305
+ {isRefreshing && !isLoading && (
306
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-theme-text-tertiary" aria-label="Refreshing issues" />
307
+ )}
308
+ <span className={clsx('badge rounded-full', countClass)}>{countLabel}</span>
177
309
  </div>
178
- <span className="badge status-unhealthy rounded-full">{problems.length}</span>
179
310
  </div>
180
311
  <div className="overflow-y-auto flex-1 min-h-0">
181
- <div className="divide-y divide-theme-border">
182
- {problems.map((p, i) => (
183
- <button
184
- key={`${p.kind}-${p.namespace}-${p.name}-${i}`}
185
- className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-theme-hover transition-colors text-left"
186
- onClick={() => onResourceClick({
187
- kind: kindToPlural(p.kind),
188
- namespace: p.namespace,
189
- name: p.name,
190
- group: p.group,
191
- })}
192
- >
193
- <StatusDot tone={mapHealthToTone(p.severity)} className="shrink-0" />
194
- <div className="min-w-0 flex-1">
195
- <div className="flex items-center gap-1.5">
196
- <span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{p.kind}</span>
197
- <span className="text-xs text-theme-text-primary truncate font-medium">{p.name}</span>
198
- <span className="text-[10px] text-theme-text-tertiary ml-auto shrink-0">{p.duration || p.age}</span>
199
- </div>
200
- <div className="flex items-center gap-1.5 mt-0.5">
201
- <span className="text-[11px] text-theme-text-secondary truncate">{p.reason}</span>
202
- <span className="text-[10px] text-theme-text-tertiary shrink-0">{p.namespace}</span>
203
- </div>
204
- {p.message && (
205
- <div className="text-[10px] text-theme-text-tertiary truncate mt-0.5">{p.message}</div>
206
- )}
312
+ {isLoading ? (
313
+ <ProblemsPanelState
314
+ icon={<Loader2 className="h-5 w-5 animate-spin text-theme-text-tertiary" />}
315
+ title="Loading issues"
316
+ body="Checking live cluster issues for the selected scope."
317
+ />
318
+ ) : hasHardError ? (
319
+ <ProblemsPanelState
320
+ icon={<AlertTriangle className="h-5 w-5 text-amber-500" />}
321
+ title="Issues unavailable"
322
+ body={formatIssueError(error)}
323
+ />
324
+ ) : (
325
+ <>
326
+ {!!error && (
327
+ <ProblemsPanelNotice tone="warning">
328
+ Issue refresh failed. Showing the last successful result.
329
+ </ProblemsPanelNotice>
330
+ )}
331
+ {visibility?.impact && (
332
+ <ProblemsPanelNotice tone="warning">
333
+ Limited visibility - {visibility.impact} Results may be incomplete.
334
+ </ProblemsPanelNotice>
335
+ )}
336
+ {isTruncated && (
337
+ <ProblemsPanelNotice tone="neutral">
338
+ Showing {totalReturned} of {issueCount} issues. Narrow by namespace to see the rest.
339
+ </ProblemsPanelNotice>
340
+ )}
341
+
342
+ {issues.length === 0 ? (
343
+ <ProblemsPanelState
344
+ icon={
345
+ hasLimitedVisibility
346
+ ? <AlertTriangle className="h-5 w-5 text-amber-500" />
347
+ : <CheckCircle className="h-5 w-5 text-green-500" />
348
+ }
349
+ title={hasLimitedVisibility ? 'No visible active issues' : 'No active issues'}
350
+ body={
351
+ hasLimitedVisibility
352
+ ? 'Radar could not read every workload source in this scope.'
353
+ : 'No critical or warning issues across the selected scope.'
354
+ }
355
+ />
356
+ ) : (
357
+ <div className="divide-y divide-theme-border">
358
+ {issues.map((issue) => {
359
+ const ref = subjectRef(issue)
360
+ const age = issue.first_seen ? formatCompactAge(issue.first_seen) : ''
361
+ const timing = issueTiming(issue)
362
+
363
+ return (
364
+ <button
365
+ key={issue.id}
366
+ className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-theme-hover transition-colors text-left"
367
+ onClick={() => onResourceClick({
368
+ kind: ref.kind,
369
+ namespace: ref.namespace ?? '',
370
+ name: ref.name,
371
+ group: ref.group ?? '',
372
+ })}
373
+ >
374
+ <StatusDot tone={issue.severity === 'critical' ? 'unhealthy' : 'degraded'} className="shrink-0" />
375
+ <div className="min-w-0 flex-1">
376
+ <div className="flex items-center gap-1.5">
377
+ <span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
378
+ <span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
379
+ {(age || timing) && (
380
+ <span className="ml-auto flex shrink-0 items-center gap-1">
381
+ {age && <span className="text-[10px] text-theme-text-tertiary tabular-nums">{age}</span>}
382
+ {timing && (
383
+ <Tooltip content={timing.tooltip} delay={100}>
384
+ <span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
385
+ </Tooltip>
386
+ )}
387
+ </span>
388
+ )}
389
+ </div>
390
+ <div className="flex items-center gap-1.5 mt-0.5">
391
+ <span className="text-[11px] text-theme-text-secondary truncate">{categoryLabel(issue.category)}</span>
392
+ <span className="text-[10px] text-theme-text-tertiary shrink-0">{groupLabel(issue.category_group)}</span>
393
+ {issue.namespace && <span className="text-[10px] text-theme-text-tertiary shrink-0">{issue.namespace}</span>}
394
+ </div>
395
+ {(issue.reason || issue.message) && (
396
+ <div className="text-[10px] text-theme-text-tertiary truncate mt-0.5">
397
+ {issue.reason}
398
+ {issue.reason && issue.message ? ' - ' : ''}
399
+ {issue.message}
400
+ </div>
401
+ )}
402
+ </div>
403
+ </button>
404
+ )
405
+ })}
207
406
  </div>
208
- </button>
209
- ))}
210
- </div>
407
+ )}
408
+ </>
409
+ )}
410
+ </div>
411
+ </div>
412
+ )
413
+ }
414
+
415
+ function ProblemsPanelState({ icon, title, body }: { icon: ReactNode; title: string; body: string }) {
416
+ return (
417
+ <div className="flex min-h-[220px] flex-col items-center justify-center gap-2 px-6 py-10 text-center">
418
+ <div className="flex h-9 w-9 items-center justify-center rounded-full bg-theme-elevated">
419
+ {icon}
420
+ </div>
421
+ <p className="text-sm font-medium text-theme-text-primary">{title}</p>
422
+ <p className="max-w-[280px] text-xs leading-5 text-theme-text-secondary">{body}</p>
423
+ </div>
424
+ )
425
+ }
426
+
427
+ function ProblemsPanelNotice({ tone, children }: { tone: 'warning' | 'neutral'; children: ReactNode }) {
428
+ return (
429
+ <div className="px-3 pt-3">
430
+ <div
431
+ className={clsx(
432
+ 'rounded-lg border px-3 py-2 text-xs leading-5',
433
+ tone === 'warning'
434
+ ? 'border-amber-500/20 bg-amber-500/10 text-theme-text-secondary'
435
+ : 'border-theme-border bg-theme-elevated text-theme-text-secondary',
436
+ )}
437
+ >
438
+ {children}
211
439
  </div>
212
440
  </div>
213
441
  )
214
442
  }
443
+
444
+ function formatIssueError(error: unknown): string {
445
+ return error instanceof Error && error.message
446
+ ? error.message
447
+ : 'Failed to load active issues.'
448
+ }