@skyhook-io/radar-app 0.2.1 → 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 (180) 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/assets/platform-icons/aws_eks.png +0 -0
  19. package/src/assets/platform-icons/azure-aks.svg +2 -0
  20. package/src/assets/platform-icons/google_kubernetes_engine.png +0 -0
  21. package/src/components/ConnectionErrorView.tsx +186 -70
  22. package/src/components/ContextSwitcher.tsx +63 -18
  23. package/src/components/DebugOverlay.tsx +5 -3
  24. package/src/components/NamespaceSwitcher.tsx +41 -0
  25. package/src/components/UserMenu.tsx +69 -21
  26. package/src/components/applications/ApplicationsView.tsx +936 -0
  27. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  28. package/src/components/audit/AuditView.tsx +65 -62
  29. package/src/components/compare/CompareViewRoute.tsx +124 -0
  30. package/src/components/compare/useCompareCandidates.ts +27 -0
  31. package/src/components/compare/useCompareLauncher.tsx +79 -0
  32. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  33. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  34. package/src/components/cost/CostTrendChart.tsx +106 -75
  35. package/src/components/cost/CostView.test.ts +12 -0
  36. package/src/components/cost/CostView.tsx +507 -223
  37. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  38. package/src/components/cost/CostViewTabs.tsx +40 -0
  39. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  40. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  41. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  42. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  43. package/src/components/cost/cloud-console.test.ts +39 -0
  44. package/src/components/cost/cloud-console.ts +81 -0
  45. package/src/components/cost/errors.ts +8 -0
  46. package/src/components/cost/format.test.ts +27 -0
  47. package/src/components/cost/format.ts +46 -0
  48. package/src/components/cost/kinds.ts +5 -0
  49. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  50. package/src/components/diagnose/AISettings.tsx +147 -0
  51. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  52. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  53. package/src/components/diagnose/Home.tsx +163 -0
  54. package/src/components/diagnose/InvestigationView.tsx +622 -0
  55. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  56. package/src/components/diagnose/launch.ts +65 -0
  57. package/src/components/diagnose/parts.tsx +1756 -0
  58. package/src/components/dock/BottomDock.tsx +2 -3
  59. package/src/components/dock/DockContext.tsx +1 -0
  60. package/src/components/dock/TerminalTab.tsx +1 -1
  61. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  62. package/src/components/dock/index.ts +1 -1
  63. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  64. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  65. package/src/components/execution/batch-run-actions.test.ts +48 -0
  66. package/src/components/execution/batch-run-actions.ts +24 -0
  67. package/src/components/execution/batch-timeline.test.ts +57 -0
  68. package/src/components/execution/batch-timeline.ts +46 -0
  69. package/src/components/execution/execution-definition.test.ts +208 -0
  70. package/src/components/execution/execution-definition.ts +245 -0
  71. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  72. package/src/components/gitops/GitOpsView.tsx +1042 -0
  73. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  74. package/src/components/helm/ChartBrowser.tsx +87 -31
  75. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  76. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  77. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  78. package/src/components/helm/HelmView.tsx +237 -96
  79. package/src/components/helm/InstallWizard.tsx +94 -38
  80. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  81. package/src/components/helm/OwnedResources.tsx +34 -59
  82. package/src/components/helm/RevisionHistory.tsx +52 -3
  83. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  84. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  85. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  86. package/src/components/helm/ValuesViewer.tsx +49 -53
  87. package/src/components/helm/helm-utils.ts +4 -0
  88. package/src/components/home/ActivitySummary.tsx +4 -1
  89. package/src/components/home/ClusterHealthCard.tsx +116 -52
  90. package/src/components/home/CostCard.tsx +21 -36
  91. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  92. package/src/components/home/HelmSummary.tsx +3 -1
  93. package/src/components/home/HomeView.tsx +339 -105
  94. package/src/components/home/MCPSetupDialog.tsx +29 -87
  95. package/src/components/home/TrafficSummary.tsx +2 -2
  96. package/src/components/home/mcpToolCatalog.ts +333 -0
  97. package/src/components/issues/IssuesPane.tsx +151 -0
  98. package/src/components/logs/LogsViewer.tsx +4 -1
  99. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  100. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  101. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  102. package/src/components/portforward/PortForwardButton.tsx +118 -47
  103. package/src/components/portforward/PortForwardManager.tsx +253 -131
  104. package/src/components/resource/HPACharts.tsx +237 -0
  105. package/src/components/resource/PVCUsageBar.tsx +59 -0
  106. package/src/components/resource/PrometheusCharts.tsx +160 -584
  107. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  108. package/src/components/resource/RestartChart.tsx +133 -0
  109. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  110. package/src/components/resource/RightsizingStrip.tsx +363 -0
  111. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  112. package/src/components/resources/CompositeRenderer.tsx +101 -0
  113. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  114. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  115. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  116. package/src/components/resources/ResourcesView.tsx +194 -17
  117. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  118. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  119. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  120. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  121. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  122. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  123. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  124. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  125. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  126. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  127. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  128. package/src/components/resources/renderers/index.ts +2 -0
  129. package/src/components/resources/resource-utils.ts +2 -1
  130. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  131. package/src/components/rightsizing/copy.test.ts +56 -0
  132. package/src/components/rightsizing/model.test.ts +227 -0
  133. package/src/components/rightsizing/model.ts +158 -0
  134. package/src/components/rightsizing/presentation.test.ts +104 -0
  135. package/src/components/rightsizing/presentation.ts +94 -0
  136. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  137. package/src/components/settings/SettingsDialog.tsx +1505 -165
  138. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  139. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  140. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  141. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  142. package/src/components/timeline/TimelineList.tsx +86 -13
  143. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  144. package/src/components/timeline/TimelineView.tsx +873 -24
  145. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  146. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  147. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  148. package/src/components/traffic/TrafficGraph.tsx +42 -24
  149. package/src/components/traffic/TrafficView.tsx +32 -19
  150. package/src/components/ui/CommandPalette.tsx +8 -215
  151. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  152. package/src/components/ui/Markdown.tsx +3 -3
  153. package/src/components/ui/Omnibar.tsx +602 -0
  154. package/src/components/ui/RadarOmnibar.tsx +52 -0
  155. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  156. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  157. package/src/components/ui/UpdateNotification.tsx +48 -36
  158. package/src/components/ui/command-items.ts +178 -0
  159. package/src/components/workload/WorkloadView.tsx +1342 -158
  160. package/src/context/ConnectionContext.tsx +146 -21
  161. package/src/context/DiagnoseCustomization.tsx +93 -0
  162. package/src/context/NavCustomization.tsx +75 -0
  163. package/src/context/TimelineSource.tsx +50 -0
  164. package/src/contexts/CapabilitiesContext.tsx +38 -8
  165. package/src/filter/FilterLocationBridge.tsx +30 -0
  166. package/src/hooks/useClusterLoadState.ts +73 -0
  167. package/src/hooks/useDocumentTitle.ts +25 -0
  168. package/src/hooks/useEventSource.ts +6 -0
  169. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  170. package/src/hooks/useMediaQuery.ts +21 -0
  171. package/src/hooks/useNavRailPinned.ts +46 -0
  172. package/src/hooks/useRecentResources.ts +49 -0
  173. package/src/index.css +162 -1
  174. package/src/index.ts +73 -1
  175. package/src/main.tsx +7 -5
  176. package/src/types/clusterLoadState.ts +33 -0
  177. package/src/types.ts +2 -0
  178. package/src/utils/auditBadges.ts +53 -0
  179. package/src/utils/navigation.ts +64 -1
  180. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,5 +1,5 @@
1
- import { useState } from 'react'
2
- import type { DashboardResponse, DashboardMetrics, DashboardCRDCount, DashboardProblem } from '../../api/client'
1
+ import { useState, type ReactNode } from 'react'
2
+ import type { DashboardResponse, DashboardMetrics, DashboardCRDCount } from '../../api/client'
3
3
  import { HealthRing } from './HealthRing'
4
4
  import {
5
5
  AlertTriangle, CheckCircle, XCircle,
@@ -12,6 +12,9 @@ import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
12
12
  import { MCPSetupDialog } from './MCPSetupDialog'
13
13
  import { pluralize, parseContextName } from '@skyhook-io/k8s-ui'
14
14
  import { Tooltip } from '../ui/Tooltip'
15
+ import gkeIcon from '../../assets/platform-icons/google_kubernetes_engine.png'
16
+ import eksIcon from '../../assets/platform-icons/aws_eks.png'
17
+ import aksIcon from '../../assets/platform-icons/azure-aks.svg'
15
18
 
16
19
  interface ClusterHealthCardProps {
17
20
  health: DashboardResponse['health']
@@ -20,12 +23,16 @@ interface ClusterHealthCardProps {
20
23
  metrics: DashboardMetrics | null
21
24
  metricsServerAvailable: boolean
22
25
  topCRDs?: DashboardCRDCount[] // Loaded lazily, may be undefined
23
- problems: DashboardProblem[]
26
+ issueCount: number
27
+ hasCriticalIssues: boolean
24
28
  nodeVersionSkew: DashboardResponse['nodeVersionSkew']
25
29
  onNavigateToKind: (kind: string, group?: string) => void
26
30
  onNavigateToView: () => void
27
31
  onWarningEventsClick?: () => void
28
- onUnhealthyClick?: () => void
32
+ onIssuesClick?: () => void
33
+ // Freshness/refresh control for the dashboard poll — rendered under the
34
+ // cluster metadata so the overview carries a freshness signal without a band.
35
+ freshness?: ReactNode
29
36
  }
30
37
 
31
38
  function getMetricsInstallHint(platform: string): string {
@@ -49,7 +56,7 @@ function MetricsUnavailableHint({ platform, metricsServerAvailable }: { platform
49
56
  content={
50
57
  <div className="space-y-1">
51
58
  <div className="font-medium">How to fix</div>
52
- <div>{isPreInstalled ? hint : <>Install by running:<br /><code className="text-[10px] opacity-80">{hint}</code></>}</div>
59
+ <div>{isPreInstalled ? hint : <>Install by running:<br /><code className="inline-code text-[10px] opacity-80">{hint}</code></>}</div>
53
60
  </div>
54
61
  }
55
62
  position="bottom"
@@ -67,13 +74,13 @@ function MetricsUnavailableHint({ platform, metricsServerAvailable }: { platform
67
74
  function getPlatformInfo(platform: string): { name: string; icon: string | null } {
68
75
  const platformLower = platform.toLowerCase()
69
76
  if (platformLower.includes('gke') || platformLower.includes('google')) {
70
- return { name: 'Google Kubernetes Engine', icon: '/icons/google_kubernetes_engine.png' }
77
+ return { name: 'Google Kubernetes Engine', icon: gkeIcon }
71
78
  }
72
79
  if (platformLower.includes('eks') || platformLower.includes('amazon') || platformLower.includes('aws')) {
73
- return { name: 'Amazon EKS', icon: '/icons/aws_eks.png' }
80
+ return { name: 'Amazon EKS', icon: eksIcon }
74
81
  }
75
82
  if (platformLower.includes('aks') || platformLower.includes('azure')) {
76
- return { name: 'Azure Kubernetes Service', icon: '/icons/azure-aks.svg' }
83
+ return { name: 'Azure Kubernetes Service', icon: aksIcon }
77
84
  }
78
85
  if (platformLower.includes('openshift')) {
79
86
  return { name: 'OpenShift', icon: null }
@@ -93,7 +100,14 @@ function getPlatformInfo(platform: string): { name: string; icon: string | null
93
100
  if (platformLower.includes('docker')) {
94
101
  return { name: 'Docker Desktop', icon: null }
95
102
  }
96
- return { name: platform || 'Kubernetes', icon: null }
103
+ // The Go backend returns "generic" for unrecognized platforms — that
104
+ // literal string is no better than the empty case, so fall back to
105
+ // the friendlier "Kubernetes" label for both. Only pass the platform
106
+ // string through when it's actually a recognizable name.
107
+ if (!platform || platformLower === 'generic') {
108
+ return { name: 'Kubernetes', icon: null }
109
+ }
110
+ return { name: platform, icon: null }
97
111
  }
98
112
 
99
113
  export function ClusterHealthCard({
@@ -103,18 +117,32 @@ export function ClusterHealthCard({
103
117
  metrics,
104
118
  metricsServerAvailable,
105
119
  topCRDs: _topCRDs,
106
- problems,
120
+ issueCount,
121
+ hasCriticalIssues,
107
122
  nodeVersionSkew,
108
123
  onNavigateToKind,
109
124
  onNavigateToView,
110
125
  onWarningEventsClick,
111
- onUnhealthyClick,
126
+ onIssuesClick,
127
+ freshness,
112
128
  }: ClusterHealthCardProps) {
113
129
  void _topCRDs // Reserved for future CRD display
114
130
 
115
131
  const [mcpDialogOpen, setMcpDialogOpen] = useState(false)
116
- const { mcpEnabled } = useCapabilitiesContext()
132
+ const caps = useCapabilitiesContext()
133
+ // Default to local mode when the backend doesn't ship a `deployment`
134
+ // field (older Radar binaries pre-0.2.2). Local rendering is the safe
135
+ // OSS-shape default — wrong-direction defaults would briefly suppress
136
+ // chrome OSS users expect to see.
137
+ const deployment = caps.deployment ?? { mode: 'local' as const }
138
+ const mcpEnabled = caps.mcpEnabled
139
+ const isCloud = deployment.mode === 'cloud'
140
+ const isInCluster = deployment.mode === 'in-cluster' || deployment.mode === 'cloud'
117
141
  const mcpUrl = `${window.location.origin}/mcp`
142
+ // In Cloud, MCP is org-wide and PAT-authed (api.radarhq.io/mcp). The OSS
143
+ // "this binary is your local MCP server" framing is wrong there — Cloud
144
+ // surfaces MCP from the hub Home dashboard instead.
145
+ const showLocalMcpCard = mcpEnabled && !isCloud
118
146
 
119
147
  const restricted = counts.restricted ?? []
120
148
  const isRestricted = (kind: string) => restricted.includes(kind)
@@ -158,13 +186,23 @@ export function ClusterHealthCard({
158
186
  { kind: 'cronjobs', label: 'CronJobs', icon: Clock, total: counts.cronJobs.total, subtitle: `${counts.cronJobs.active} active` },
159
187
  ]
160
188
  const platformInfo = getPlatformInfo(cluster.platform)
161
- // The raw cluster.name is the kubeconfig context (e.g.
162
- // `gke_koalabackend_us-east1-b_nonprod-cluster-us-east1`). That string
163
- // is the user's primary orientation cue, but the bit they actually
164
- // recognize is the short clusterName. We promote that, push the raw
165
- // path into a tooltip, and surface project/region as muted metadata.
189
+ // Headline-name derivation has three branches, in priority order:
190
+ // 1. Local-kubeconfig users get the parsed short clusterName from a
191
+ // string like `gke_koalabackend_us-east1-b_nonprod-cluster-us-east1`
192
+ // (the meaningful tail). Account/region are surfaced separately
193
+ // below as muted metadata, and the raw path is exposed via tooltip
194
+ // on the headline element.
195
+ // 2. In-cluster mode (deployment.mode === 'in-cluster' OR 'cloud')
196
+ // has no meaningful kubeconfig context — bootstrap sets it to
197
+ // the literal "in-cluster" sentinel. Fall back to the platform
198
+ // label ("Google Kubernetes Engine") which IS recognizable.
199
+ // 3. Last resort: the literal cluster.name, or "Cluster".
200
+ // When the card is rendered embedded (cloud mode), the H2 itself is
201
+ // suppressed below — the hub shell already shows the cluster name in
202
+ // its top bar.
166
203
  const parsedContext = parseContextName(cluster.name || '')
167
- const headlineName = parsedContext.clusterName || cluster.name || 'Cluster'
204
+ const rawHeadline = parsedContext.clusterName || cluster.name || 'Cluster'
205
+ const headlineName = isInCluster ? platformInfo.name : rawHeadline
168
206
 
169
207
  return (
170
208
  <div className="rounded-xl bg-theme-surface shadow-theme-sm overflow-hidden">
@@ -181,31 +219,46 @@ export function ClusterHealthCard({
181
219
  )}
182
220
  <span className="text-xs text-theme-text-secondary truncate">{platformInfo.name}</span>
183
221
  </div>
184
- <h2
185
- className="text-xl font-semibold text-theme-text-primary truncate mb-1.5 leading-tight"
186
- title={cluster.name}
187
- >
188
- {headlineName}
189
- </h2>
222
+ {/* In Cloud, the hub shell already shows the cluster name in
223
+ its top bar; rendering it again here is redundant and
224
+ makes the card feel like a label rather than content. */}
225
+ {!isCloud && (
226
+ // In-cluster mode's cluster.name is the literal "in-cluster"
227
+ // sentinel, which would leak via the hover tooltip even though
228
+ // the visible text falls back to the platform label. Render no
229
+ // tooltip in that case; local mode keeps it so users can hover
230
+ // to see the full kubeconfig context path.
231
+ <Tooltip content={isInCluster ? '' : cluster.name} wrapperClassName="!block min-w-0">
232
+ <h2 className="text-xl font-semibold text-theme-text-primary truncate leading-tight mb-1.5">
233
+ {headlineName}
234
+ </h2>
235
+ </Tooltip>
236
+ )}
190
237
  <div className="flex flex-col gap-0.5 text-xs text-theme-text-tertiary">
191
238
  {(parsedContext.account || parsedContext.region) && (
192
- <span className="truncate font-mono" title={[parsedContext.account, parsedContext.region].filter(Boolean).join(' · ')}>
239
+ <Tooltip content={[parsedContext.account, parsedContext.region].filter(Boolean).join(' · ')} wrapperClassName="min-w-0">
240
+ <span className="truncate font-mono">
193
241
  {[parsedContext.account, parsedContext.region].filter(Boolean).join(' · ')}
194
242
  </span>
243
+ </Tooltip>
195
244
  )}
196
245
  {cluster.version && (
197
246
  <span>Kubernetes {cluster.version}</span>
198
247
  )}
199
248
  <span><span className="font-mono">{counts.namespaces}</span> namespaces</span>
200
- {cluster.name && cluster.name !== headlineName && (
201
- <span
202
- className="font-mono text-[10px] text-theme-text-disabled break-all leading-snug pt-0.5"
203
- title={cluster.name}
204
- >
249
+ {/* Show raw kubeconfig context as muted metadata only when
250
+ it differs from the headline AND we're in local mode
251
+ (in-cluster has no meaningful context name, cloud
252
+ shell already renders the canonical name). */}
253
+ {cluster.name && cluster.name !== headlineName && deployment.mode === 'local' && (
254
+ <Tooltip content={cluster.name}>
255
+ <span className="font-mono text-[10px] text-theme-text-disabled break-all leading-snug pt-0.5">
205
256
  {cluster.name}
206
257
  </span>
258
+ </Tooltip>
207
259
  )}
208
260
  </div>
261
+ {freshness && <div className="mt-2">{freshness}</div>}
209
262
  {nodeVersionSkew && (
210
263
  <Tooltip
211
264
  content={
@@ -229,8 +282,11 @@ export function ClusterHealthCard({
229
282
  </span>
230
283
  </Tooltip>
231
284
  )}
232
- {/* MCP Server indicator */}
233
- {mcpEnabled && (
285
+ {/* MCP Server indicator. OSS-only: in Cloud, MCP discovery
286
+ lives at the hub level (org-wide endpoint, PAT-authed)
287
+ rather than per-cluster, so this localhost/no-auth card
288
+ would mislead a Cloud user. */}
289
+ {showLocalMcpCard && (
234
290
  <button
235
291
  onClick={() => setMcpDialogOpen(true)}
236
292
  className="flex items-center gap-2 mt-3 px-2.5 py-2 bg-purple-500/5 hover:bg-purple-500/10 border border-purple-500/20 rounded-md transition-colors w-full"
@@ -238,9 +294,11 @@ export function ClusterHealthCard({
238
294
  <Radio className="w-3.5 h-3.5 text-purple-400 animate-pulse shrink-0" />
239
295
  <div className="flex flex-col gap-0.5 min-w-0 flex-1 text-left">
240
296
  <span className="text-xs font-medium text-purple-400">MCP Server Live</span>
241
- <span className="text-[10px] text-theme-text-tertiary truncate font-mono" title={mcpUrl}>
297
+ <Tooltip content={mcpUrl} wrapperClassName="min-w-0">
298
+ <span className="text-[10px] text-theme-text-tertiary truncate font-mono">
242
299
  HTTP · {mcpUrl}
243
300
  </span>
301
+ </Tooltip>
244
302
  </div>
245
303
  <Info className="w-3.5 h-3.5 text-purple-400/60 shrink-0" />
246
304
  </button>
@@ -339,12 +397,14 @@ export function ClusterHealthCard({
339
397
  <Cpu className="w-3.5 h-3.5 text-theme-text-tertiary" />
340
398
  CPU
341
399
  </div>
342
- <ResourceBar
343
- label="Used"
344
- used={formatCPUMillicores(metrics.cpu.usageMillis)}
345
- total={formatCPUMillicores(metrics.cpu.capacityMillis)}
346
- percent={metrics.cpu.usagePercent}
347
- />
400
+ {metricsServerAvailable && (
401
+ <ResourceBar
402
+ label="Used"
403
+ used={formatCPUMillicores(metrics.cpu.usageMillis)}
404
+ total={formatCPUMillicores(metrics.cpu.capacityMillis)}
405
+ percent={metrics.cpu.usagePercent}
406
+ />
407
+ )}
348
408
  <ResourceBar
349
409
  label="Requested"
350
410
  used={formatCPUMillicores(metrics.cpu.requestsMillis)}
@@ -359,12 +419,14 @@ export function ClusterHealthCard({
359
419
  <MemoryStick className="w-3.5 h-3.5 text-theme-text-tertiary" />
360
420
  Memory
361
421
  </div>
362
- <ResourceBar
363
- label="Used"
364
- used={formatMemoryMiB(metrics.memory.usageMillis)}
365
- total={formatMemoryMiB(metrics.memory.capacityMillis)}
366
- percent={metrics.memory.usagePercent}
367
- />
422
+ {metricsServerAvailable && (
423
+ <ResourceBar
424
+ label="Used"
425
+ used={formatMemoryMiB(metrics.memory.usageMillis)}
426
+ total={formatMemoryMiB(metrics.memory.capacityMillis)}
427
+ percent={metrics.memory.usagePercent}
428
+ />
429
+ )}
368
430
  <ResourceBar
369
431
  label="Requested"
370
432
  used={formatMemoryMiB(metrics.memory.requestsMillis)}
@@ -373,7 +435,7 @@ export function ClusterHealthCard({
373
435
  />
374
436
  </div>
375
437
  )}
376
- {!metrics?.cpu && !metrics?.memory && (
438
+ {!metricsServerAvailable && (
377
439
  <MetricsUnavailableHint platform={cluster.platform} metricsServerAvailable={metricsServerAvailable} />
378
440
  )}
379
441
  </div>
@@ -387,24 +449,26 @@ export function ClusterHealthCard({
387
449
  {/* Left column: Warning indicators (aligned with cluster info) */}
388
450
  <div className="flex flex-col justify-center gap-1 w-1/4 shrink-0 pr-4 border-r border-theme-border/50">
389
451
  {health.warningEvents > 0 && (
452
+ <Tooltip content="Native Kubernetes Warning events (e.g., ImagePullBackOff, FailedScheduling)" wrapperClassName="w-fit">
390
453
  <button
391
454
  onClick={onWarningEventsClick}
392
- title="Native Kubernetes Warning events (e.g., ImagePullBackOff, FailedScheduling)"
393
455
  className="badge status-degraded w-fit gap-1.5 hover:opacity-80 transition-opacity"
394
456
  >
395
457
  <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
396
458
  <span><span className="font-mono">{health.warningEvents}</span> Warning Events</span>
397
459
  </button>
460
+ </Tooltip>
398
461
  )}
399
- {problems.length > 0 && (
462
+ {issueCount > 0 && (
463
+ <Tooltip content="View grouped live operational issues" wrapperClassName="w-fit">
400
464
  <button
401
- onClick={onUnhealthyClick}
402
- title="View timeline of unhealthy/degraded workload events"
403
- className="badge status-unhealthy w-fit gap-1.5 hover:opacity-80 transition-opacity"
465
+ onClick={onIssuesClick}
466
+ className={clsx('badge w-fit gap-1.5 hover:opacity-80 transition-opacity', hasCriticalIssues ? 'status-unhealthy' : 'status-degraded')}
404
467
  >
405
468
  <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
406
- <span>View unhealthy workload events</span>
469
+ <span>{pluralize(issueCount, 'Active Issue')}</span>
407
470
  </button>
471
+ </Tooltip>
408
472
  )}
409
473
  </div>
410
474
 
@@ -1,6 +1,12 @@
1
1
  import type { OpenCostSummary } from '../../api/client'
2
2
  import { useOpenCostSummary } from '../../api/client'
3
3
  import { DollarSign } from 'lucide-react'
4
+ import {
5
+ formatCostPerHour,
6
+ formatProjectedDailyRate,
7
+ formatProjectedMonthlyCost,
8
+ formatProjectedMonthlyRate,
9
+ } from '../cost/format'
4
10
 
5
11
  export function CostCard({ onNavigate }: { onNavigate?: () => void }) {
6
12
  const { data } = useOpenCostSummary()
@@ -15,7 +21,6 @@ export function CostCard({ onNavigate }: { onNavigate?: () => void }) {
15
21
 
16
22
  function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNavigate?: () => void }) {
17
23
  const hourlyCost = data.totalHourlyCost ?? 0
18
- const monthlyCost = hourlyCost * 730
19
24
  const namespaces = data.namespaces ?? []
20
25
  const topNamespaces = namespaces.slice(0, 5)
21
26
 
@@ -30,10 +35,10 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
30
35
  <div className="flex flex-col h-full w-full">
31
36
  <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
32
37
  <div className="flex items-center gap-2">
33
- <DollarSign className="w-4 h-4 text-indigo-500" />
34
- <span className="text-xs font-semibold uppercase tracking-wider text-indigo-500">Cost Insights</span>
38
+ <DollarSign className="w-4 h-4 text-accent-text" />
39
+ <span className="text-xs font-semibold uppercase tracking-wider text-accent-text">Cost Insights</span>
35
40
  {namespaces.length > 0 && (
36
- <span className="badge-sm bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40">
41
+ <span className="badge-sm border border-theme-border bg-accent-muted text-accent-text">
37
42
  {namespaces.length} ns
38
43
  </span>
39
44
  )}
@@ -45,13 +50,14 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
45
50
  <div className="flex items-baseline gap-3 mb-3">
46
51
  <div className="flex items-baseline gap-1">
47
52
  <span className="text-2xl font-bold text-theme-text-primary tabular-nums">
48
- {formatCost(hourlyCost)}
53
+ {formatProjectedMonthlyCost(hourlyCost)}
49
54
  </span>
50
- <span className="text-xs text-theme-text-tertiary">/hr</span>
55
+ <span className="text-xs text-theme-text-tertiary">/mo</span>
51
56
  </div>
52
- <div className="flex items-baseline gap-1 text-theme-text-secondary">
53
- <span className="text-sm font-medium tabular-nums">~{formatCost(monthlyCost)}</span>
54
- <span className="text-[10px] text-theme-text-tertiary">/mo</span>
57
+ <div className="flex items-baseline gap-1.5 text-theme-text-secondary">
58
+ <span className="text-xs font-medium tabular-nums">{formatProjectedDailyRate(hourlyCost)}</span>
59
+ <span className="text-[10px] text-theme-text-quaternary">·</span>
60
+ <span className="text-xs font-medium tabular-nums">{formatCostPerHour(hourlyCost)}</span>
55
61
  </div>
56
62
  </div>
57
63
 
@@ -63,30 +69,25 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
63
69
  <div key={ns.name} className="flex items-center gap-2">
64
70
  <span className="text-[11px] text-theme-text-secondary truncate w-24 shrink-0">{ns.name}</span>
65
71
  <div className="flex-1 h-2 rounded-full overflow-hidden bg-theme-hover">
66
- <div
67
- className="h-full rounded-full bg-indigo-500/60"
68
- style={{ width: `${Math.max(pct, 2)}%` }}
69
- />
72
+ <div className="h-full rounded-full bg-indigo-500/60" style={{ width: `${Math.max(pct, 2)}%` }} />
70
73
  </div>
71
- <span className="text-[10px] text-theme-text-tertiary tabular-nums w-14 text-right shrink-0">
72
- {formatCost(ns.hourlyCost)}/h
74
+ <span className="text-[10px] text-theme-text-tertiary tabular-nums w-20 text-right shrink-0">
75
+ {formatProjectedMonthlyRate(ns.hourlyCost)}
73
76
  </span>
74
77
  </div>
75
78
  )
76
79
  })}
77
80
  {namespaces.length > 5 && (
78
- <span className="text-[10px] text-theme-text-tertiary">
79
- +{namespaces.length - 5} more namespaces
80
- </span>
81
+ <span className="text-[10px] text-theme-text-tertiary">+{namespaces.length - 5} more namespaces</span>
81
82
  )}
82
83
  </div>
83
84
  </div>
84
85
 
85
86
  <div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-between">
86
87
  <span className="text-[10px] text-theme-text-tertiary">
87
- {data.currency ?? 'USD'} &middot; {data.window ?? '1h'} window
88
+ {data.currency ?? 'USD'} &middot; projected monthly from {data.window ?? '1h'} window
88
89
  </span>
89
- <span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-indigo-500">
90
+ <span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-accent-text">
90
91
  OpenCost
91
92
  </span>
92
93
  </div>
@@ -94,19 +95,3 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
94
95
  </div>
95
96
  )
96
97
  }
97
-
98
- function formatCost(value: number): string {
99
- if (value >= 1000) {
100
- return `$${(value / 1000).toFixed(1)}k`
101
- }
102
- if (value >= 1) {
103
- return `$${value.toFixed(2)}`
104
- }
105
- if (value >= 0.01) {
106
- return `$${value.toFixed(3)}`
107
- }
108
- if (value > 0) {
109
- return `$${value.toFixed(4)}`
110
- }
111
- return '$0.00'
112
- }
@@ -0,0 +1,110 @@
1
+ import { GitBranch, AlertCircle, CheckCircle2 } from 'lucide-react'
2
+ import type { DashboardGitOpsControllers, DashboardGitOpsController } from '../../api/client'
3
+ import { clsx } from 'clsx'
4
+
5
+ interface GitOpsControllersCardProps {
6
+ data: DashboardGitOpsControllers
7
+ onNavigate: () => void
8
+ }
9
+
10
+ // GitOpsControllersCard surfaces Argo CD / Flux controller pod health
11
+ // on the Home dashboard so an operator can spot "source-controller is
12
+ // CrashLoopBackOff" before drilling into individual GitOps applications
13
+ // and seeing the *symptoms* (apps stuck OutOfSync, sources unfetched).
14
+ //
15
+ // Capability-gated: the parent only renders this card when the backend
16
+ // actually detected controllers in the cluster (DashboardResponse.gitopsControllers
17
+ // is null on non-GitOps clusters). The card itself doesn't have an
18
+ // "empty state" branch — by the time we get here, we have something to show.
19
+ export function GitOpsControllersCard({ data, onNavigate }: GitOpsControllersCardProps) {
20
+ const headerTone =
21
+ data.status === 'crashing'
22
+ ? 'text-red-500'
23
+ : data.status === 'degraded'
24
+ ? 'text-amber-400'
25
+ : 'text-emerald-500'
26
+
27
+ const headerLabel =
28
+ data.status === 'crashing'
29
+ ? 'Controllers crashing'
30
+ : data.status === 'degraded'
31
+ ? 'Controllers degraded'
32
+ : 'Controllers healthy'
33
+
34
+ // Group controllers by tool so the card reads as two sections (Argo +
35
+ // Flux) when both are installed. Operators with only one tool see a
36
+ // single-section card without empty placeholders.
37
+ const argo = data.controllers.filter((c) => c.tool === 'argocd')
38
+ const flux = data.controllers.filter((c) => c.tool === 'fluxcd')
39
+
40
+ return (
41
+ <button
42
+ type="button"
43
+ onClick={onNavigate}
44
+ className="group h-[260px] rounded-xl bg-theme-surface shadow-theme-sm hover:-translate-y-1 hover:shadow-theme-md transition-all duration-200 text-left animate-fade-in-up"
45
+ >
46
+ <div className="flex flex-col h-full w-full">
47
+ <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
48
+ <div className="flex items-center gap-2">
49
+ <GitBranch className={clsx('h-4 w-4', headerTone)} />
50
+ <span className={clsx('text-xs font-semibold uppercase tracking-wider', headerTone)}>
51
+ GitOps Controllers
52
+ </span>
53
+ </div>
54
+ <span className={clsx('text-[11px] font-medium', headerTone)}>{headerLabel}</span>
55
+ </div>
56
+
57
+ <div className="flex-1 min-h-0 overflow-y-auto px-5 py-3 flex flex-col gap-3">
58
+ {argo.length > 0 && <ControllerSection label="Argo CD" controllers={argo} />}
59
+ {flux.length > 0 && <ControllerSection label="Flux CD" controllers={flux} />}
60
+ </div>
61
+ </div>
62
+ </button>
63
+ )
64
+ }
65
+
66
+ function ControllerSection({ label, controllers }: { label: string; controllers: DashboardGitOpsController[] }) {
67
+ return (
68
+ <div>
69
+ <div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">{label}</div>
70
+ <div className="flex flex-col gap-1">
71
+ {controllers.map((c) => (
72
+ <ControllerRow key={`${c.tool}-${c.name}`} c={c} />
73
+ ))}
74
+ </div>
75
+ </div>
76
+ )
77
+ }
78
+
79
+ function ControllerRow({ c }: { c: DashboardGitOpsController }) {
80
+ // Per-row tone matches the per-controller status. We don't reuse
81
+ // mapHealthToTone because the controller status vocabulary
82
+ // (healthy/degraded/crashing/pending) is different from resource
83
+ // health (Healthy/Degraded/Missing/etc). Mapping inline keeps the
84
+ // intent obvious.
85
+ const tone =
86
+ c.status === 'crashing'
87
+ ? 'text-red-500'
88
+ : c.status === 'degraded' || c.status === 'pending'
89
+ ? 'text-amber-400'
90
+ : 'text-emerald-500'
91
+ const Icon = c.status === 'crashing' ? AlertCircle : c.status === 'degraded' || c.status === 'pending' ? AlertCircle : CheckCircle2
92
+ return (
93
+ <div className="flex items-center justify-between gap-2 text-[12px]">
94
+ <div className="flex min-w-0 items-center gap-1.5">
95
+ <Icon className={clsx('h-3 w-3 shrink-0', tone)} />
96
+ <span className="truncate text-theme-text-primary">{c.name}</span>
97
+ </div>
98
+ <div className="flex shrink-0 items-center gap-1.5 text-[11px] text-theme-text-secondary">
99
+ <span>
100
+ {c.ready}/{c.total} ready
101
+ </span>
102
+ {c.crashReason && (
103
+ <span className={clsx('rounded border px-1 py-px text-[10px] font-medium', 'border-red-500/40 bg-red-500/10 text-red-400')}>
104
+ {c.crashReason}
105
+ </span>
106
+ )}
107
+ </div>
108
+ </div>
109
+ )
110
+ }
@@ -88,11 +88,13 @@ export function HelmSummary({ data, onNavigate }: HelmSummaryProps) {
88
88
  <span className="text-xs font-medium text-theme-text-secondary">
89
89
  {data.errorCode === 'unconfigured' ? 'Helm not configured' : 'Helm unavailable'}
90
90
  </span>
91
- <span className="text-[11px] mt-1 text-center px-2 truncate max-w-full" title={data.error}>
91
+ <Tooltip content={data.error} wrapperClassName="!block mt-1 min-w-0 text-center">
92
+ <span className="text-[11px] text-center px-2 truncate max-w-full">
92
93
  {data.errorCode === 'unconfigured'
93
94
  ? 'Set rbac.helm=true in the Radar Helm chart values.'
94
95
  : data.error}
95
96
  </span>
97
+ </Tooltip>
96
98
  </div>
97
99
  ) : !data.releases || data.releases.length === 0 ? (
98
100
  <div className="flex items-center justify-center h-full py-4 text-xs text-theme-text-tertiary">