@skyhook-io/radar-app 1.8.2 → 1.8.5

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 (43) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +412 -146
  3. package/src/RadarApp.tsx +21 -1
  4. package/src/api/client.ts +144 -12
  5. package/src/components/ConnectionErrorView.tsx +1 -1
  6. package/src/components/ContextSwitcher.tsx +5 -1
  7. package/src/components/NamespaceSwitcher.tsx +21 -278
  8. package/src/components/applications/ApplicationsView.tsx +13 -1
  9. package/src/components/audit/AuditView.tsx +11 -2
  10. package/src/components/cost/CostView.tsx +12 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  12. package/src/components/gitops/GitOpsView.tsx +23 -17
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +448 -67
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +18 -7
  17. package/src/components/helm/OwnedResources.tsx +14 -50
  18. package/src/components/helm/RevisionHistory.tsx +9 -5
  19. package/src/components/helm/ValuesViewer.tsx +41 -11
  20. package/src/components/home/ClusterHealthCard.tsx +6 -1
  21. package/src/components/home/HomeView.tsx +12 -1
  22. package/src/components/home/mcpToolCatalog.ts +8 -8
  23. package/src/components/issues/IssuesPane.tsx +29 -18
  24. package/src/components/portforward/PortForwardButton.tsx +69 -25
  25. package/src/components/portforward/PortForwardManager.tsx +18 -4
  26. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  27. package/src/components/resources/ResourcesView.tsx +45 -1
  28. package/src/components/resources/renderers/PodRenderer.tsx +7 -2
  29. package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
  30. package/src/components/timeline/TimelineView.tsx +26 -2
  31. package/src/components/traffic/TrafficView.tsx +17 -10
  32. package/src/components/ui/Markdown.tsx +2 -2
  33. package/src/components/ui/Omnibar.tsx +1 -1
  34. package/src/components/ui/UpdateNotification.tsx +5 -10
  35. package/src/components/workload/WorkloadView.tsx +57 -8
  36. package/src/contexts/CapabilitiesContext.tsx +8 -0
  37. package/src/filter/FilterLocationBridge.tsx +30 -0
  38. package/src/hooks/useDocumentTitle.ts +25 -0
  39. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  40. package/src/index.ts +15 -0
  41. package/src/main.tsx +5 -3
  42. package/src/utils/auditBadges.ts +53 -0
  43. package/src/utils/navigation.ts +5 -3
@@ -1,5 +1,6 @@
1
1
  import { useMemo, type ReactNode } from 'react'
2
2
  import { useDashboard, useDashboardCRDs, useDashboardHelm, useIssues, type IssuesResponse } from '../../api/client'
3
+ import { useConnection } from '../../context/ConnectionContext'
3
4
  import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
4
5
  import { TopologyPreview } from './TopologyPreview'
5
6
  import { HelmSummary } from './HelmSummary'
@@ -11,6 +12,7 @@ import { CostCard } from './CostCard'
11
12
  import { GitOpsControllersCard } from './GitOpsControllersCard'
12
13
  import {
13
14
  AuditCard,
15
+ FreshnessControl,
14
16
  PaneLoader,
15
17
  StatusDot,
16
18
  categoryLabel,
@@ -39,7 +41,8 @@ interface HomeViewProps {
39
41
  }
40
42
 
41
43
  export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts }: HomeViewProps) {
42
- const { data, isLoading, error } = useDashboard(namespaces)
44
+ const { data, isLoading, error, dataUpdatedAt, refetch } = useDashboard(namespaces)
45
+ const { connection } = useConnection()
43
46
  const { data: issuesData, isLoading: issuesLoading, isFetching: issuesFetching, error: issuesError } = useIssues(namespaces)
44
47
  const issues = issuesData?.issues ?? []
45
48
  const issueCount = issuesData?.total_matched ?? issuesData?.total ?? issues.length
@@ -108,6 +111,14 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
108
111
  )}
109
112
  {/* Row 1: Cluster Health Card (combined health + resource counts) */}
110
113
  <ClusterHealthCard
114
+ freshness={
115
+ <FreshnessControl
116
+ mode="auto"
117
+ dataUpdatedAt={dataUpdatedAt}
118
+ onRefresh={() => refetch()}
119
+ connectionState={connection.state}
120
+ />
121
+ }
111
122
  health={data.health}
112
123
  counts={data.resourceCounts}
113
124
  cluster={data.cluster}
@@ -109,9 +109,9 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
109
109
  },
110
110
  {
111
111
  name: 'diagnose',
112
- desc: 'One-call root-cause bundle. Workloads get spec + resourceContext + current AND previous logs across pods + warning events + startup blockers; GitOps reconcilers get status summary + parsed related issues.',
112
+ desc: 'One-call root-cause bundle. Workloads get spec + resourceContext + current AND previous logs across pods + warning events + startup blockers; GitOps reconcilers, including Flux HelmRelease, get status summary + parsed related issues.',
113
113
  params: [
114
- { arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, application, kustomization, or helmrelease' },
114
+ { arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, application, kustomization, or Flux HelmRelease' },
115
115
  { arg: 'namespace', required: true, desc: 'resource namespace' },
116
116
  { arg: 'name', required: true, desc: 'resource name' },
117
117
  { arg: 'container', desc: 'specific container (defaults to all)' },
@@ -126,7 +126,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
126
126
  },
127
127
  {
128
128
  name: 'get_changes',
129
- desc: 'Recent resource creates, updates, and deletes from the Kubernetes timeline. Helm release history is separate; use list_helm_releases or get_helm_release include=history,operations for failed upgrades and rollbacks.',
129
+ desc: 'Recent meaningful changes from the Kubernetes timeline plus native Helm deployment history (source=helm). Includes failed upgrades, rollbacks, and current Helm revisions; sourcesErrored marks partial source failures.',
130
130
  params: [
131
131
  { arg: 'namespace', desc: 'filter to a specific namespace' },
132
132
  { arg: 'kind', desc: 'filter to a resource kind (e.g. Deployment)' },
@@ -152,13 +152,13 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
152
152
  },
153
153
  {
154
154
  name: 'get_helm_release',
155
- desc: 'Detailed Helm release info with owned resources, health, Flux ownership, and current lastOperation; include history and operations for the full revision trail.',
155
+ desc: 'Detailed Helm release info with owned resources, health, Flux ownership, current lastOperation, operationInsight, hooks, and failed/running hook diagnostics with live Job/Pod/Event/redacted-log evidence when available.',
156
156
  params: [
157
157
  { arg: 'namespace', required: true, desc: 'Helm storage namespace; use storageNamespace from list_helm_releases when present' },
158
158
  { arg: 'name', required: true, desc: 'release name' },
159
- { arg: 'include', desc: 'values, history, operations, diff' },
160
- { arg: 'diff_revision_1', desc: 'first revision for diff' },
161
- { arg: 'diff_revision_2', desc: 'second revision for diff (defaults to current)' },
159
+ { arg: 'include', desc: 'values, history, operations, diff, values_diff, notes_diff, resource_diff' },
160
+ { arg: 'diff_revision_1', desc: 'first revision for any diff include' },
161
+ { arg: 'diff_revision_2', desc: 'second revision for any diff include (defaults to current)' },
162
162
  ],
163
163
  },
164
164
  {
@@ -172,7 +172,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
172
172
  },
173
173
  {
174
174
  name: 'issues',
175
- desc: 'Ranked list of what is broken right now — failing workloads, dangling references, scheduling blockers, and false CRD conditions. Live operational state (distinct from get_cluster_audit posture).',
175
+ desc: 'Ranked list of what is broken right now — failing workloads, active native Helm release failures or stuck pending operations, dangling references, scheduling blockers, and false CRD conditions. Native Helm rows use group=helm.sh.',
176
176
  params: [
177
177
  { arg: 'namespace', desc: 'filter to one namespace' },
178
178
  { arg: 'severity', desc: 'comma-separated: critical, warning' },
@@ -1,11 +1,13 @@
1
1
  import { useMemo, useState } from 'react'
2
2
  import { useIssues } from '../../api/client'
3
+ import { useConnection } from '../../context/ConnectionContext'
3
4
  import type { SelectedResource } from '../../types'
4
5
  import {
5
6
  IssuesView,
6
7
  PaneLoader,
7
8
  PageHeader,
8
9
  SummaryTile,
10
+ FreshnessControl,
9
11
  ISSUE_SEVERITIES,
10
12
  ISSUE_SEVERITY_LABEL,
11
13
  type IssueResourceRef,
@@ -30,7 +32,8 @@ interface IssuesPaneProps {
30
32
  // the header status tiles (clickable → filter), matching the Applications /
31
33
  // GitOps header-tile pattern rather than Hub's fleet facet sidebar.
32
34
  export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps) {
33
- const { data, isLoading, error } = useIssues(namespaces)
35
+ const { data, isLoading, error, dataUpdatedAt, refetch } = useIssues(namespaces)
36
+ const { connection } = useConnection()
34
37
  const [severityFilter, setSeverityFilter] = useState<Set<IssueSeverity>>(new Set())
35
38
 
36
39
  const allIssues = useMemo(() => data?.issues ?? [], [data])
@@ -70,23 +73,31 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
70
73
  title="Issues"
71
74
  description="Live cluster problems — crashes, scheduling failures, bad references — grouped by the resource they affect."
72
75
  actions={
73
- allIssues.length > 0 ? (
74
- <>
75
- <SummaryTile label={allIssues.length === 1 ? 'issue' : 'issues'} value={allIssues.length} />
76
- {ISSUE_SEVERITIES.map((s) =>
77
- totals[s] > 0 || severityFilter.has(s) ? (
78
- <SummaryTile
79
- key={s}
80
- label={ISSUE_SEVERITY_LABEL[s]}
81
- value={totals[s]}
82
- tone={SEVERITY_TONE[s]}
83
- active={severityFilter.has(s)}
84
- onClick={() => toggleSeverity(s)}
85
- />
86
- ) : null,
87
- )}
88
- </>
89
- ) : undefined
76
+ <>
77
+ <FreshnessControl
78
+ mode="auto"
79
+ dataUpdatedAt={dataUpdatedAt}
80
+ onRefresh={() => refetch()}
81
+ connectionState={connection.state}
82
+ />
83
+ {allIssues.length > 0 && (
84
+ <>
85
+ <SummaryTile label={allIssues.length === 1 ? 'issue' : 'issues'} value={allIssues.length} />
86
+ {ISSUE_SEVERITIES.map((s) =>
87
+ totals[s] > 0 || severityFilter.has(s) ? (
88
+ <SummaryTile
89
+ key={s}
90
+ label={ISSUE_SEVERITY_LABEL[s]}
91
+ value={totals[s]}
92
+ tone={SEVERITY_TONE[s]}
93
+ active={severityFilter.has(s)}
94
+ onClick={() => toggleSeverity(s)}
95
+ />
96
+ ) : null,
97
+ )}
98
+ </>
99
+ )}
100
+ </>
90
101
  }
91
102
  />
92
103
 
@@ -1,8 +1,10 @@
1
1
  import { useState, useRef, useEffect } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { Plug, ChevronDown, Loader2, Globe, Monitor, Copy, Check, X, Terminal } from 'lucide-react'
3
4
  import { clsx } from 'clsx'
4
- import { useAvailablePorts, useClusterInfo, AvailablePort } from '../../api/client'
5
+ import { useAvailablePorts, AvailablePort } from '../../api/client'
5
6
  import { useStartPortForward } from './PortForwardManager'
7
+ import { useIsLocalDeployment } from '../../contexts/CapabilitiesContext'
6
8
  import { validatePort } from '@skyhook-io/k8s-ui/utils/validators'
7
9
  import { Tooltip } from '../ui/Tooltip'
8
10
 
@@ -22,6 +24,13 @@ interface KubectlDialogInfo {
22
24
  port: number
23
25
  }
24
26
 
27
+ // kubectl port-forward (and the live forward, which uses the same transport) is
28
+ // TCP-only — UDP/SCTP can't be forwarded (kubernetes/kubernetes#47862). Treat an
29
+ // unset protocol as TCP.
30
+ export function isPortForwardable(protocol?: string): boolean {
31
+ return (protocol || 'TCP').toUpperCase() === 'TCP'
32
+ }
33
+
25
34
  function buildKubectlCommand(type: 'pod' | 'service', namespace: string, name: string, localPort: number, remotePort: number) {
26
35
  const resource = type === 'pod' ? `pod/${name}` : `svc/${name}`
27
36
  const portArg = localPort === remotePort ? `${remotePort}` : `${localPort}:${remotePort}`
@@ -70,17 +79,23 @@ function KubectlCommandDialog({
70
79
 
71
80
  useEffect(() => {
72
81
  const handleKeyDown = (e: KeyboardEvent) => {
73
- if (e.key === 'Escape') onClose()
82
+ if (e.key !== 'Escape') return
83
+ // Capture + stopPropagation so Escape closes only this dialog, not the
84
+ // drawer behind it (whose Escape shortcut listens in the bubble phase).
85
+ e.stopPropagation()
86
+ onClose()
74
87
  }
75
- document.addEventListener('keydown', handleKeyDown)
76
- return () => document.removeEventListener('keydown', handleKeyDown)
88
+ document.addEventListener('keydown', handleKeyDown, true)
89
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
77
90
  }, [onClose])
78
91
 
79
92
  useEffect(() => {
80
93
  dialogRef.current?.focus()
81
94
  }, [])
82
95
 
83
- return (
96
+ // Portal to <body>: the drawer is a transformed ancestor that would otherwise
97
+ // trap this position:fixed dialog inside the drawer instead of centering it.
98
+ return createPortal(
84
99
  <div className="fixed inset-0 z-50 flex items-center justify-center">
85
100
  <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
86
101
  <div
@@ -103,7 +118,7 @@ function KubectlCommandDialog({
103
118
 
104
119
  <div className="p-4 space-y-3">
105
120
  <p className="text-sm text-theme-text-secondary">
106
- Radar is running in-cluster, so port forwarding must be run from your local terminal.
121
+ Forward this port to your own machine run it from your terminal:
107
122
  </p>
108
123
  <div className="flex flex-col gap-1">
109
124
  <div className="flex items-center gap-2 text-sm text-theme-text-secondary">
@@ -147,12 +162,13 @@ function KubectlCommandDialog({
147
162
  {copied ? 'Copied' : copyFallback ? 'Press Ctrl+C' : 'Copy'}
148
163
  </button>
149
164
  </div>
150
- <p className="text-xs text-theme-text-secondary">
151
- Requires kubectl and authentication to this cluster.
165
+ <p className="text-xs text-theme-text-tertiary">
166
+ You&apos;ll need <code className="inline-code">kubectl</code> and access to this cluster.
152
167
  </p>
153
168
  </div>
154
169
  </div>
155
- </div>
170
+ </div>,
171
+ document.body,
156
172
  )
157
173
  }
158
174
 
@@ -168,12 +184,16 @@ export function PortForwardButton({
168
184
  const [listenAddress, setListenAddress] = useState<'127.0.0.1' | '0.0.0.0'>('127.0.0.1')
169
185
  const dropdownRef = useRef<HTMLDivElement>(null)
170
186
 
171
- const { data: clusterInfo } = useClusterInfo()
187
+ const isLocal = useIsLocalDeployment()
172
188
  const { data, isLoading } = useAvailablePorts(type, namespace, name)
173
189
  const startPortForward = useStartPortForward()
174
190
 
175
191
  const ports = data?.ports || []
176
- const inCluster = clusterInfo?.inCluster ?? false
192
+ // Decide copy-command vs live forward from the SAME deployment signal that
193
+ // gates whether the button shows at all — so the two can't disagree (and we
194
+ // don't race a separate /cluster-info fetch that defaults to "not in-cluster").
195
+ // Cloud runs in-cluster too, so anything not-local uses the copy command.
196
+ const inCluster = !isLocal
177
197
  const isPending = !inCluster && startPortForward.isPending
178
198
  const resourceName = type === 'service' ? (serviceName || name) : name
179
199
 
@@ -204,10 +224,15 @@ export function PortForwardButton({
204
224
  }
205
225
 
206
226
  function renderButton() {
207
- // If no ports available, show disabled button
208
- if (!isLoading && ports.length === 0) {
227
+ // kubectl port-forward is TCP-only never offer UDP/SCTP ports as targets.
228
+ const forwardable = ports.filter((p) => isPortForwardable(p.protocol))
229
+
230
+ // No forwardable ports: disabled button. Distinguish "no ports at all" from
231
+ // "ports exist but are all UDP" so the operator isn't left guessing.
232
+ if (!isLoading && forwardable.length === 0) {
233
+ const udpOnly = ports.length > 0
209
234
  return (
210
- <Tooltip content="No ports available">
235
+ <Tooltip content={udpOnly ? "kubectl port-forward doesn't support UDP" : 'No ports available'}>
211
236
  <button
212
237
  disabled
213
238
  className={clsx(
@@ -216,18 +241,18 @@ export function PortForwardButton({
216
241
  )}
217
242
  >
218
243
  <Plug className="w-4 h-4" />
219
- No Ports
244
+ {udpOnly ? 'No TCP Ports' : 'No Ports'}
220
245
  </button>
221
246
  </Tooltip>
222
247
  )
223
248
  }
224
249
 
225
- // If only one port, forward directly on click (most common case)
226
- if (ports.length === 1) {
250
+ // If only one forwardable port, forward directly on click (most common case)
251
+ if (forwardable.length === 1) {
227
252
  return (
228
- <Tooltip content={`Port forward to ${ports[0].port}`}>
253
+ <Tooltip content={`Port forward to ${forwardable[0].port}`}>
229
254
  <button
230
- onClick={() => handlePortSelect(ports[0])}
255
+ onClick={() => handlePortSelect(forwardable[0])}
231
256
  disabled={isPending}
232
257
  className={clsx(
233
258
  'flex items-center gap-2 px-3 py-2 bg-theme-elevated text-theme-text-primary text-sm rounded-lg hover:bg-theme-hover transition-colors disabled:opacity-50 disabled:pointer-events-none',
@@ -239,7 +264,7 @@ export function PortForwardButton({
239
264
  ) : (
240
265
  <Plug className="w-4 h-4" />
241
266
  )}
242
- Forward :{ports[0].port}
267
+ Forward :{forwardable[0].port}
243
268
  </button>
244
269
  </Tooltip>
245
270
  )
@@ -306,7 +331,7 @@ export function PortForwardButton({
306
331
  <div className="px-2 py-1.5 text-xs text-theme-text-disabled border-b border-theme-border">
307
332
  Select port to forward
308
333
  </div>
309
- {ports.map((port, i) => (
334
+ {forwardable.map((port, i) => (
310
335
  <button
311
336
  key={i}
312
337
  onClick={() => handlePortSelect(port)}
@@ -355,11 +380,15 @@ export function PortForwardInlineButton({
355
380
  protocol = 'TCP',
356
381
  disabled = false,
357
382
  }: PortForwardInlineButtonProps) {
358
- const { data: clusterInfo } = useClusterInfo()
383
+ const isLocal = useIsLocalDeployment()
359
384
  const startPortForward = useStartPortForward()
360
385
  const [dialogInfo, setDialogInfo] = useState<KubectlDialogInfo | null>(null)
361
386
 
362
- const inCluster = clusterInfo?.inCluster ?? false
387
+ // Decide copy-command vs live forward from the SAME deployment signal that
388
+ // gates whether the button shows at all — so the two can't disagree (and we
389
+ // don't race a separate /cluster-info fetch that defaults to "not in-cluster").
390
+ // Cloud runs in-cluster too, so anything not-local uses the copy command.
391
+ const inCluster = !isLocal
363
392
  const isPending = !inCluster && startPortForward.isPending
364
393
 
365
394
  const handleClick = (e: React.MouseEvent) => {
@@ -378,15 +407,30 @@ export function PortForwardInlineButton({
378
407
  }
379
408
  }
380
409
 
410
+ // UDP/SCTP can't be port-forwarded — show a muted, non-interactive hint that
411
+ // explains why rather than a button that would copy a command that can't work.
412
+ if (!isPortForwardable(protocol)) {
413
+ return (
414
+ <Tooltip content="kubectl port-forward doesn't support UDP">
415
+ <span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-theme-elevated rounded text-xs text-theme-text-tertiary opacity-60 cursor-default">
416
+ {port}/{protocol}
417
+ <Plug className="w-3 h-3" />
418
+ </span>
419
+ </Tooltip>
420
+ )
421
+ }
422
+
381
423
  return (
382
424
  <>
383
- <Tooltip content={`Port forward ${port}`}>
425
+ <Tooltip content={inCluster ? 'Copy a kubectl port-forward command' : `Port forward ${port}`}>
384
426
  <button
385
427
  onClick={handleClick}
386
428
  disabled={disabled || isPending}
387
429
  className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-theme-elevated hover:bg-accent-muted rounded text-xs transition-colors disabled:opacity-50 disabled:hover:bg-theme-elevated disabled:pointer-events-none"
388
430
  >
389
- {port}/{protocol}
431
+ {/* In-cluster this opens a copy-command dialog rather than forwarding now;
432
+ the trailing "…" signals "opens a dialog" (it doesn't fire immediately). */}
433
+ {port}/{protocol}{inCluster ? '…' : ''}
390
434
  {isPending ? (
391
435
  <Loader2 className="w-3 h-3 animate-spin" />
392
436
  ) : (
@@ -31,7 +31,7 @@ import { Tooltip } from '../ui/Tooltip'
31
31
  import { useToast } from '../ui/Toast'
32
32
  import { openExternal } from '../../utils/navigation'
33
33
  import { apiUrl } from '../../api/config'
34
- import { apiFetch } from '../../api/client'
34
+ import { apiFetch, useCapabilities } from '../../api/client'
35
35
  import { pluralize } from '@skyhook-io/k8s-ui'
36
36
 
37
37
  // --- Types -------------------------------------------------------------------
@@ -84,7 +84,7 @@ function buildRecreateBody(session: PortForwardSession, overrides: { localPort:
84
84
 
85
85
  // --- Shared query ------------------------------------------------------------
86
86
 
87
- function usePortForwardQuery() {
87
+ function usePortForwardQuery(enabled: boolean) {
88
88
  return useQuery<PortForwardSession[]>({
89
89
  queryKey: ['portforwards'],
90
90
  queryFn: async () => {
@@ -92,6 +92,10 @@ function usePortForwardQuery() {
92
92
  if (!res.ok) throw new Error('Failed to fetch port forwards')
93
93
  return res.json()
94
94
  },
95
+ // Port-forward is a local-binary feature; in-cluster (Radar Cloud) the
96
+ // capability is false, so don't poll an endpoint that can never return a
97
+ // usable session. Also covers RBAC-denied users.
98
+ enabled,
95
99
  // 30s fallback poll — user mutations invalidate immediately, but out-of-band
96
100
  // session death (pod restart, OOM kill, server-side cleanup) only surfaces on
97
101
  // the next tick.
@@ -144,12 +148,21 @@ interface PortForwardContextValue {
144
148
  const PortForwardContext = createContext<PortForwardContextValue | null>(null)
145
149
 
146
150
  export function PortForwardProvider({ children }: { children: ReactNode }) {
151
+ // Gate the session-list poll on runtime mode, not the RBAC capability: port-forward
152
+ // only works when radar runs as a local binary, so in-cluster (Radar Cloud) we never
153
+ // poll /portforwards. We deliberately do NOT gate on `portForward` (RBAC) — a local
154
+ // user with portforward rights in only some namespaces must still see/stop the
155
+ // sessions they start (the start buttons gate per-namespace separately). Using the
156
+ // resolved value (undefined while capabilities load → no poll) keeps Cloud silent on
157
+ // first paint.
158
+ const { data: caps } = useCapabilities()
159
+ const canPortForward = caps?.deployment?.mode === 'local'
147
160
  const {
148
161
  data: sessions = [],
149
162
  isLoading,
150
163
  isError: isQueryError,
151
164
  error: queryError,
152
- } = usePortForwardQuery()
165
+ } = usePortForwardQuery(canPortForward)
153
166
  const activeSessions = sessions.filter((s) => s.status !== 'stopped')
154
167
  const errorSessions = sessions.filter((s) => s.status === 'error')
155
168
  const count = activeSessions.length
@@ -970,6 +983,7 @@ export function useStartPortForward() {
970
983
 
971
984
  // Backwards-compat: existing consumers that just want a count number.
972
985
  export function usePortForwardCount() {
973
- const { data: sessions = [] } = usePortForwardQuery()
986
+ const { data: caps } = useCapabilities()
987
+ const { data: sessions = [] } = usePortForwardQuery(caps?.deployment?.mode === 'local')
974
988
  return sessions.filter((s) => s.status !== 'stopped').length
975
989
  }
@@ -14,8 +14,10 @@ interface ResourceDetailDrawerProps {
14
14
  expanded?: boolean
15
15
  /** Called when user clicks collapse in expanded mode */
16
16
  onCollapse?: () => void
17
- /** Called when user clicks expand button */
18
- onExpand?: (resource: SelectedResource) => void
17
+ /** Called when user clicks expand button (opts.yaml = expanding from YAML view) */
18
+ onExpand?: (resource: SelectedResource, opts?: { yaml?: boolean }) => void
19
+ /** Hide the collapse-to-drawer control (mobile: no drawer to collapse to). Default true. */
20
+ canCollapseToDrawer?: boolean
19
21
  /** Navigate to another resource within expanded WorkloadView */
20
22
  onNavigateToResource?: (resource: SelectedResource) => void
21
23
  /** Top offset for the drawer (px). Defaults to Radar's 49px header height;
@@ -26,16 +28,19 @@ interface ResourceDetailDrawerProps {
26
28
  export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
27
29
  return (
28
30
  <BaseResourceDetailDrawer {...props}>
29
- {({ resource, expanded, initialTab, onClose, onExpand, onBack, onNavigateToResource, onCollapseToDrawer }) => (
31
+ {({ resource, expanded, active, initialTab, onClose, onExpand, onExpandIntent, onCancelExpandIntent, onBack, onNavigateToResource, onCollapseToDrawer }) => (
30
32
  <WorkloadView
31
33
  kind={resource.kind}
32
34
  namespace={resource.namespace}
33
35
  name={resource.name}
34
36
  group={resource.group}
35
37
  expanded={expanded}
38
+ active={active}
36
39
  initialTab={initialTab}
37
40
  onClose={onClose}
38
41
  onExpand={onExpand}
42
+ onExpandIntent={onExpandIntent}
43
+ onCancelExpandIntent={onCancelExpandIntent}
39
44
  onBack={onBack ?? (() => {})}
40
45
  onNavigateToResource={onNavigateToResource}
41
46
  onCollapseToDrawer={onCollapseToDrawer}
@@ -1,9 +1,12 @@
1
1
  import { useState, useMemo, useCallback, useEffect } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
- import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads } from '../../api/client'
4
+ import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads, useAudit } from '../../api/client'
5
+ import { isBadgeWorthy } from '../../utils/auditBadges'
6
+ import type { AuditBadgeMessage } from '@skyhook-io/k8s-ui'
5
7
  import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
6
8
  import { useAPIResources } from '../../api/apiResources'
9
+ import { useConnection } from '../../context/ConnectionContext'
7
10
  import { initNavigationMap } from '@skyhook-io/k8s-ui'
8
11
  import { usePinnedKinds } from '../../hooks/useFavorites'
9
12
  import { useOpenLogs, useOpenWorkloadLogs } from '../dock'
@@ -60,6 +63,7 @@ function resourceCountKey(kind: NonNullable<SelectedKindInfo>): string {
60
63
  export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) {
61
64
  const location = useLocation()
62
65
  const navigate = useNavigate()
66
+ const { connection } = useConnection()
63
67
 
64
68
  const { data: capabilities } = useCapabilities()
65
69
  const namespaceForCapabilities = namespaces.length === 1 ? namespaces[0] : undefined
@@ -137,6 +141,44 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
137
141
  return match?.isCrd ?? (!!selectedKind.group) // default: has group = likely CRD
138
142
  }, [selectedKind, apiResources])
139
143
 
144
+ // The canonical Kind for the selected resource. selectedKind.kind is the plural
145
+ // URL segment for CRDs/grouped kinds (e.g. "ingressroutes", "ingresses") — only
146
+ // core no-group kinds resolve to the real Kind there — so resolve it via
147
+ // discovery to match audit findings, which key by the real Kind ("IngressRoute").
148
+ const selectedKindCanonical = useMemo(() => {
149
+ if (!selectedKind) return undefined
150
+ const match = apiResources?.find(r => r.name === selectedKind.name && r.group === selectedKind.group)
151
+ ?? CORE_RESOURCES.find(r => r.name === selectedKind.name && r.group === selectedKind.group)
152
+ return match?.kind ?? selectedKind.kind
153
+ }, [selectedKind, apiResources])
154
+
155
+ // Cluster Audit findings for the selected kind, keyed by "namespace/name" for
156
+ // the resource list. The list shows ONE kind at a time, so ns/name is enough;
157
+ // we still match the finding's group (built-ins → real group, CRDs → "") so a
158
+ // kind shared across groups doesn't bleed findings across the two lists. Only
159
+ // "badge-worthy" findings count (reference-integrity / lifecycle) — posture
160
+ // and best-practice nags fire near-universally and would just be noise.
161
+ const audit = useAudit(namespaces)
162
+ const auditBadges = useMemo(() => {
163
+ if (!selectedKind || !audit.data?.findings) return undefined
164
+ const wantGroup = isSelectedCrd ? '' : selectedKind.group
165
+ const map: Record<string, { danger: number; warning: number; messages: AuditBadgeMessage[] }> = {}
166
+ for (const f of audit.data.findings) {
167
+ if (f.kind !== selectedKindCanonical || (f.group ?? '') !== wantGroup) continue
168
+ if (!isBadgeWorthy(f, audit.data.checks)) continue
169
+ const k = `${f.namespace || ''}/${f.name}`
170
+ const cur = map[k] ?? { danger: 0, warning: 0, messages: [] }
171
+ if (f.severity === 'danger') cur.danger++
172
+ else if (f.severity === 'warning') cur.warning++
173
+ cur.messages.push({ severity: f.severity, message: f.message })
174
+ map[k] = cur
175
+ }
176
+ for (const cur of Object.values(map)) {
177
+ cur.messages.sort((a, b) => (a.severity === 'danger' ? 0 : 1) - (b.severity === 'danger' ? 0 : 1))
178
+ }
179
+ return map
180
+ }, [audit.data?.findings, audit.data?.checks, selectedKind, selectedKindCanonical, isSelectedCrd])
181
+
140
182
  const selectedCountKey = selectedKind ? resourceCountKey(selectedKind) : ''
141
183
  const selectedCount = selectedCountKey ? countsData?.counts[selectedCountKey] : undefined
142
184
  const selectedCountUnavailable = selectedCountKey ? countsData?.unavailable?.includes(selectedCountKey) ?? false : false
@@ -288,12 +330,14 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
288
330
  resourceReasons={countsData?.reasons}
289
331
  resourceUnavailable={countsData?.unavailable}
290
332
  selectedKindQuery={selectedKindQueryResult}
333
+ connectionState={connection.state}
291
334
  largeListGuard={largeListGuard}
292
335
  onSelectedKindChange={setSelectedKind}
293
336
  topPodMetrics={topPodMetrics}
294
337
  topNodeMetrics={topNodeMetrics}
295
338
  certExpiry={certExpiry}
296
339
  certExpiryError={certExpiryError}
340
+ auditBadges={auditBadges}
297
341
  // Pinned kinds
298
342
  pinned={pinned}
299
343
  togglePin={togglePin}
@@ -2,7 +2,7 @@ import { PodRenderer as BasePodRenderer } from '@skyhook-io/k8s-ui/components/re
2
2
  import type { CopyHandler } from '@skyhook-io/k8s-ui/components/ui/drawer-components'
3
3
  import type { ResolvedEnvFrom } from '@skyhook-io/k8s-ui'
4
4
  import { useOpenTerminal, useOpenLogs } from '../../dock'
5
- import { useNamespacedCapabilities } from '../../../contexts/CapabilitiesContext'
5
+ import { useNamespacedCapabilities, useIsLocalDeployment } from '../../../contexts/CapabilitiesContext'
6
6
  import { usePodMetrics, usePodMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
7
7
  import { useRBACSubject } from '../../../api/rbac'
8
8
  import { PortForwardInlineButton } from '../../portforward/PortForwardButton'
@@ -27,6 +27,11 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
27
27
 
28
28
  // Capabilities (namespace-scoped: re-checks RBAC if globally denied)
29
29
  const { canExec, canViewLogs, canPortForward } = useNamespacedCapabilities(namespace)
30
+ // Show the port-forward affordance for a live forward (local + RBAC) OR when
31
+ // not local — in-cluster/Cloud surfaces a copy-paste kubectl command instead.
32
+ // The button itself picks live vs. copy-command based on deployment mode.
33
+ const isLocal = useIsLocalDeployment()
34
+ const showPortForward = canPortForward || !isLocal
30
35
 
31
36
  // Metrics
32
37
  const { data: metrics } = usePodMetrics(namespace, podName)
@@ -65,7 +70,7 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
65
70
  rbacError={rbacError as Error | null}
66
71
  canExec={canExec}
67
72
  canViewLogs={canViewLogs}
68
- canPortForward={canPortForward}
73
+ canPortForward={showPortForward}
69
74
  onOpenTerminal={(params) => openTerminal(params)}
70
75
  onOpenLogsPanel={(params) => openLogsPanel(params)}
71
76
  renderPortAction={({ namespace: ns, podName: pod, port, protocol, disabled }) => (