@skyhook-io/radar-app 1.3.2 → 1.3.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/App.tsx CHANGED
@@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query'
6
6
  import { useNavigate, useLocation, useSearchParams, useNavigationType, NavigationType } from 'react-router-dom'
7
7
  import { HomeView } from './components/home/HomeView'
8
8
  import { DebugOverlay } from './components/DebugOverlay'
9
- import { TopologyGraph, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind } from '@skyhook-io/k8s-ui'
9
+ import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind } from '@skyhook-io/k8s-ui'
10
10
  import { TimelineView } from './components/timeline/TimelineView'
11
11
  import { ResourcesView } from './components/resources/ResourcesView'
12
12
  import { serializeColumnFilters } from './components/resources/resource-utils'
@@ -17,6 +17,7 @@ import { HelmView } from './components/helm/HelmView'
17
17
  import { TrafficView } from './components/traffic/TrafficView'
18
18
  import { CostView } from './components/cost/CostView'
19
19
  import { AuditView } from './components/audit/AuditView'
20
+ import { IssuesPane } from './components/issues/IssuesPane'
20
21
  import { GitOpsView } from './components/gitops/GitOpsView'
21
22
  import { HelmReleaseDrawer } from './components/helm/HelmReleaseDrawer'
22
23
  import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './components/portforward/PortForwardManager'
@@ -41,7 +42,7 @@ import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/con
41
42
  import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
42
43
  import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
43
44
  import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
44
- import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck, GitBranch, Shield as ShieldIcon } from 'lucide-react'
45
+ import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck, GitBranch } from 'lucide-react'
45
46
  import { useTheme } from './context/ThemeContext'
46
47
  import { Tooltip } from './components/ui/Tooltip'
47
48
  import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
@@ -91,34 +92,8 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
91
92
  ])
92
93
 
93
94
  // Convert API resource name back to topology node ID prefix
94
- function apiResourceToNodeIdPrefix(apiResource: string): string {
95
- const prefixMap: Record<string, string> = {
96
- 'pods': 'pod',
97
- 'services': 'service',
98
- 'deployments': 'deployment',
99
- 'daemonsets': 'daemonset',
100
- 'statefulsets': 'statefulset',
101
- 'replicasets': 'replicaset',
102
- 'ingresses': 'ingress',
103
- 'gateways': 'gateway',
104
- 'httproutes': 'httproute',
105
- 'grpcroutes': 'grpcroute',
106
- 'tcproutes': 'tcproute',
107
- 'tlsroutes': 'tlsroute',
108
- 'configmaps': 'configmap',
109
- 'secrets': 'secret',
110
- 'horizontalpodautoscalers': 'horizontalpodautoscaler',
111
- 'jobs': 'job',
112
- 'cronjobs': 'cronjob',
113
- 'persistentvolumeclaims': 'persistentvolumeclaim',
114
- 'namespaces': 'namespace',
115
- 'httpproxies': 'httpproxy', // Contour
116
- }
117
- return prefixMap[apiResource] || apiResource.replace(/s$/, '')
118
- }
119
-
120
95
  // Extended MainView type that includes traffic and cost
121
- type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops' | 'compare'
96
+ type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops' | 'compare' | 'issues'
122
97
 
123
98
  // Extract view from URL path
124
99
  function getViewFromPath(pathname: string): ExtendedMainView {
@@ -134,6 +109,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
134
109
  if (path === 'audit') return 'audit'
135
110
  if (path === 'gitops') return 'gitops'
136
111
  if (path === 'compare') return 'compare'
112
+ if (path === 'issues') return 'issues'
137
113
  return 'home'
138
114
  }
139
115
 
@@ -301,6 +277,8 @@ function AppInner() {
301
277
  // Topology filter state
302
278
  const [visibleKinds, setVisibleKinds] = useState<Set<NodeKind>>(() => new Set(DEFAULT_VISIBLE_KINDS))
303
279
  const [filterSidebarCollapsed, setFilterSidebarCollapsed] = useState(false)
280
+ // Topology node-search → canvas focus request (nonce lets the same node re-focus)
281
+ const [topologyFocus, setTopologyFocus] = useState<{ id: string; nonce: number } | null>(null)
304
282
  // Track CRD kinds that have been auto-added to visibleKinds so we don't override user toggles
305
283
  const seededCRDKindsRef = useRef<Set<string>>(new Set())
306
284
 
@@ -344,6 +322,14 @@ function AppInner() {
344
322
  // Suppress the mainView-change clear effect during controlled expand/collapse transitions.
345
323
  const suppressViewClearRef = useRef(false)
346
324
 
325
+ // On a history Pop (back/forward) the URL is authoritative. The URL-write
326
+ // effect, running with not-yet-synced state, would otherwise write the stale
327
+ // state back and revert the Pop — and oscillate with the URL→state read
328
+ // effect (infinite re-render, React #185, blank page). Suppress the writer
329
+ // for the synchronous reconciliation burst after a Pop, then auto-clear (see
330
+ // the arming effect) so later user-driven writes are never affected.
331
+ const skipUrlWriteAfterPopRef = useRef(false)
332
+
347
333
  // Close resource drawer when the /resources route no longer matches the
348
334
  // selected drawer resource. This covers both in-view kind switches and
349
335
  // cross-kind navigations from expanded drawers (for example Node -> View Pods).
@@ -409,6 +395,27 @@ function AppInner() {
409
395
  }
410
396
  }, [selectedResource])
411
397
 
398
+ // Navigate from a detector finding (Audit / Issues) to the resources list for
399
+ // its kind, opening the resource. Shared by both queues — the body was
400
+ // duplicated verbatim at each render site. Encodes the opened resource in the
401
+ // URL (?resource=ns/name) — the same deep-link shape the resources view
402
+ // round-trips — so refresh/share keeps the drawer open instead of dropping it.
403
+ const navigateToResourceList = useCallback((resource: SelectedResource) => {
404
+ const pluralKind = kindToPlural(resource.kind)
405
+ setSelectedResource({ ...resource, kind: pluralKind })
406
+ const newParams = new URLSearchParams(searchParams)
407
+ newParams.delete('kind')
408
+ newParams.delete('mode')
409
+ newParams.delete('group')
410
+ newParams.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
411
+ if (resource.group) {
412
+ newParams.set('apiGroup', resource.group)
413
+ } else {
414
+ newParams.delete('apiGroup')
415
+ }
416
+ navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
417
+ }, [searchParams, navigate])
418
+
412
419
  // Collapse from expanded WorkloadView back to drawer
413
420
  const handleCollapseFromExpanded = useCallback(() => {
414
421
  suppressViewClearRef.current = true
@@ -843,10 +850,35 @@ function AppInner() {
843
850
  // eslint-disable-next-line react-hooks/exhaustive-deps -- namespaces and setActiveNamespace are intentionally excluded; we only react to server-side changes.
844
851
  }, [namespaceScope, namespaceScopeKey])
845
852
 
853
+ // Arm the skip on every history Pop (location.key changes per nav), then
854
+ // clear it on the next macrotask. The revert/oscillation is a synchronous
855
+ // re-render burst, so a macrotask-deferred clear covers it; clearing
856
+ // afterward means a stale arm can't survive into an unrelated later write
857
+ // (e.g. a Pop that changes none of the write effect's deps would otherwise
858
+ // leave the flag set and silently drop the next user-driven URL write).
859
+ useEffect(() => {
860
+ if (navigationType !== NavigationType.Pop) {
861
+ // Any non-Pop navigation clears the guard. Without this, a Push/Replace
862
+ // that lands before the macrotask fires would run this cleanup (cancelling
863
+ // the timeout) and re-run as a no-op, leaving the flag stuck true and
864
+ // silently suppressing all later URL writes.
865
+ skipUrlWriteAfterPopRef.current = false
866
+ return
867
+ }
868
+ skipUrlWriteAfterPopRef.current = true
869
+ const id = setTimeout(() => { skipUrlWriteAfterPopRef.current = false }, 0)
870
+ return () => clearTimeout(id)
871
+ }, [location.key, navigationType])
872
+
846
873
  // Update URL query params when state changes (path is handled by setMainView)
847
874
  // Read from window.location.search (not React Router's searchParams) to preserve
848
875
  // params set by child components via window.history.replaceState (e.g., kind from ResourcesView).
849
876
  useEffect(() => {
877
+ // Don't write (and revert) the URL while state is still catching up to a
878
+ // Pop — the read effect below owns syncing state from the popped URL. The
879
+ // flag auto-clears on the next macrotask, so this never blocks a later
880
+ // user-driven write.
881
+ if (skipUrlWriteAfterPopRef.current) return
850
882
  const currentSearch = window.location.search
851
883
  const params = new URLSearchParams(currentSearch)
852
884
 
@@ -1045,6 +1077,21 @@ function AppInner() {
1045
1077
  }
1046
1078
  }, [displayedTopology, visibleKinds, namespaces, topologyMode])
1047
1079
 
1080
+ // The graph node id of the currently open resource, used to highlight it on
1081
+ // the canvas. Looked up from the topology (not reconstructed) because node
1082
+ // ids are `<lowercaseKind>/<ns>/<name>` with special prefixes for CRD
1083
+ // collisions — rebuilding the string can't match those reliably.
1084
+ const selectedNodeId = useMemo(() => {
1085
+ if (!selectedResource) return undefined
1086
+ const ns = selectedResource.namespace || ''
1087
+ const match = topology?.nodes.find(n =>
1088
+ ((n.data.namespace as string) || '') === ns &&
1089
+ n.name === selectedResource.name &&
1090
+ (kindToPlural(n.kind) === selectedResource.kind || n.kind === selectedResource.kind)
1091
+ )
1092
+ return match?.id
1093
+ }, [selectedResource, topology])
1094
+
1048
1095
  // Filter handlers
1049
1096
  const handleToggleKind = useCallback((kind: NodeKind) => {
1050
1097
  setVisibleKinds(prev => {
@@ -1253,18 +1300,6 @@ function AppInner() {
1253
1300
  </button>
1254
1301
  )}
1255
1302
 
1256
- {/* My Permissions — what the current user can do in the cluster,
1257
- computed live by the apiserver via SelfSubjectRulesReview.
1258
- Available in embedded mode too — Radar Hub users still benefit
1259
- from "why can't I do X" debugging. */}
1260
- <button
1261
- onClick={() => setShowMyPermissions(true)}
1262
- className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1263
- title="My permissions in this cluster"
1264
- >
1265
- <ShieldIcon className="w-4 h-4" />
1266
- </button>
1267
-
1268
1303
  {/* User menu (when auth enabled) — hidden in embedded mode;
1269
1304
  host app typically provides its own via rightExtras. */}
1270
1305
  {!navCustomization.embedded && <UserMenu />}
@@ -1497,13 +1532,27 @@ function AppInner() {
1497
1532
  groupingMode={effectiveGroupingMode}
1498
1533
  hideGroupHeader={hideGroupHeader}
1499
1534
  onNodeClick={handleNodeClick}
1500
- selectedNodeId={selectedResource ? `${apiResourceToNodeIdPrefix(selectedResource.kind)}-${selectedResource.namespace}-${selectedResource.name}` : undefined}
1535
+ selectedNodeId={selectedNodeId}
1501
1536
  paused={topologyPaused}
1502
1537
  onTogglePause={handleTogglePause}
1503
1538
  onMaximizeNamespace={(ns) => setActiveNamespace.mutate({ namespaces: [ns] })}
1504
1539
  namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
1505
1540
  onClearNamespace={namespaces.length >= 1 ? () => setActiveNamespace.mutate({ namespaces: [] }) : undefined}
1506
1541
  namespacesKey={namespaces.join(',')}
1542
+ focusNodeId={topologyFocus?.id}
1543
+ focusNonce={topologyFocus?.nonce}
1544
+ />
1545
+
1546
+ {/* Topology node search overlay - top left */}
1547
+ <TopologySearch
1548
+ nodes={filteredTopology?.nodes ?? []}
1549
+ allNodes={topology?.nodes}
1550
+ viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Traffic' : 'Resources'}
1551
+ onNodeSelect={handleNodeClick}
1552
+ onZoomToNode={(id) => setTopologyFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }))}
1553
+ // Stack below the namespace breadcrumb (shown only for a single
1554
+ // namespace) so the two don't overlap in the top-left corner.
1555
+ triggerClassName={namespaces.length === 1 ? 'top-12 left-3' : 'top-3 left-3'}
1507
1556
  />
1508
1557
 
1509
1558
  {/* Topology controls overlay - top right */}
@@ -1612,21 +1661,18 @@ function AppInner() {
1612
1661
  <AuditView
1613
1662
  namespaces={namespaces}
1614
1663
  onBack={() => setMainView('home')}
1615
- onNavigateToResource={(resource) => {
1616
- const pluralKind = kindToPlural(resource.kind)
1617
- setSelectedResource({ ...resource, kind: pluralKind })
1618
- const newParams = new URLSearchParams(searchParams)
1619
- newParams.delete('kind')
1620
- newParams.delete('mode')
1621
- newParams.delete('group')
1622
- newParams.delete('resource')
1623
- if (resource.group) {
1624
- newParams.set('apiGroup', resource.group)
1625
- } else {
1626
- newParams.delete('apiGroup')
1627
- }
1628
- navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
1629
- }}
1664
+ onNavigateToResource={navigateToResourceList}
1665
+ />
1666
+ )}
1667
+
1668
+ {/* Issues — per-cluster live triage queue (hidden route: not yet in the
1669
+ nav `views` list; reachable at /issues). Same shared <IssuesView> the
1670
+ Hub fleet uses; resource clicks open the standard resource drawer. */}
1671
+ {mainView === 'issues' && (
1672
+ <IssuesPane
1673
+ namespaces={namespaces}
1674
+ onBack={() => setMainView('home')}
1675
+ onNavigateToResource={navigateToResourceList}
1630
1676
  />
1631
1677
  )}
1632
1678
 
@@ -1749,7 +1795,14 @@ function AppInner() {
1749
1795
  {diagnosticsOverlay.shouldRender && <DiagnosticsOverlay isOpen={diagnosticsOverlay.isOpen} onClose={() => setShowDiagnostics(false)} />}
1750
1796
 
1751
1797
  {/* Settings dialog */}
1752
- <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
1798
+ <SettingsDialog
1799
+ open={showSettings}
1800
+ onClose={() => setShowSettings(false)}
1801
+ onShowMyPermissions={() => {
1802
+ setShowSettings(false)
1803
+ setShowMyPermissions(true)
1804
+ }}
1805
+ />
1753
1806
  <MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
1754
1807
 
1755
1808
  {/* Debug overlay - only in dev mode */}
package/src/api/client.ts CHANGED
@@ -237,7 +237,7 @@ export interface DashboardCRDCount {
237
237
  }
238
238
 
239
239
  // Re-export shared types from k8s-ui — single source of truth
240
- import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta, Check } from '@skyhook-io/k8s-ui'
240
+ import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta, Check, Issue } from '@skyhook-io/k8s-ui'
241
241
  export type DashboardAudit = AuditCardData
242
242
  export type { AuditFinding, ResourceGroup, CheckMeta, Check }
243
243
 
@@ -338,6 +338,34 @@ export function useAudit(namespaces: string[] = []) {
338
338
  })
339
339
  }
340
340
 
341
+ // Live cluster Issues — the grouped triage queue (radar's /api/issues =
342
+ // internal/issues.Compose+Classify+Group). Single-cluster here; the Hub fleet
343
+ // view fans the same shape across clusters. keepPreviousData semantics via
344
+ // placeholderData so the queue doesn't flash empty on the 30s refresh.
345
+ // total = rows returned (after the cap); total_matched = rows that matched
346
+ // before the cap. total_matched > total means the queue was truncated — surface
347
+ // that honestly rather than presenting a capped list as if it were complete.
348
+ export interface IssuesResponse {
349
+ issues: Issue[]
350
+ total?: number
351
+ total_matched?: number
352
+ // Present only when RBAC visibility is incomplete (absent = full access).
353
+ // state 'degraded' means core workload reads are denied, so an empty list may
354
+ // mean "can't see" rather than "nothing broken" — the UI must say so.
355
+ visibility?: { state?: string; impact?: string }
356
+ }
357
+
358
+ export function useIssues(namespaces: string[] = []) {
359
+ const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
360
+ return useQuery<IssuesResponse>({
361
+ queryKey: ['issues', namespaces],
362
+ queryFn: () => fetchJSON(`/issues${params}`),
363
+ staleTime: 30000,
364
+ refetchInterval: 30000,
365
+ placeholderData: (prev) => prev,
366
+ })
367
+ }
368
+
341
369
  export function useResourceAudit(kind: string, namespace: string, name: string) {
342
370
  return useQuery<AuditFinding[]>({
343
371
  queryKey: ['audit', 'resource', kind, namespace, name],
@@ -197,13 +197,13 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
197
197
  Context: {connection.context ? (
198
198
  <ClusterName name={connection.context} />
199
199
  ) : (
200
- <span className="font-mono text-theme-text-primary">(none)</span>
200
+ <span className="inline-code">(none)</span>
201
201
  )}
202
202
  </p>
203
203
 
204
204
  {connection.clusterName && (
205
205
  <p className="text-sm text-theme-text-secondary mb-4">
206
- Cluster: <span className="font-mono text-theme-text-primary">{connection.clusterName}</span>
206
+ Cluster: <span className="inline-code">{connection.clusterName}</span>
207
207
  </p>
208
208
  )}
209
209
 
@@ -307,7 +307,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
307
307
  <div className="text-sm mt-1">
308
308
  <p>No Helm repositories configured.</p>
309
309
  <p className="mt-1">
310
- Add repositories using <code className="bg-theme-elevated px-1 rounded">helm repo add</code>
310
+ Add repositories using <code className="inline-code">helm repo add</code>
311
311
  </p>
312
312
  <p className="mt-2">
313
313
  Or try searching on <button onClick={() => setChartSource('artifacthub')} className="text-accent-text hover:underline">ArtifactHub</button>
@@ -476,7 +476,9 @@ function LocalChartCard({ chart, onSelect }: LocalChartCardProps) {
476
476
  )}
477
477
  <div className="flex-1 min-w-0">
478
478
  <div className="flex items-center gap-2">
479
- <h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
479
+ <Tooltip content={chart.name} wrapperClassName="min-w-0 flex-1">
480
+ <h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
481
+ </Tooltip>
480
482
  {chart.deprecated && (
481
483
  <span className={clsx('px-1 py-0.5 text-[10px] rounded', SEVERITY_BADGE.warning)}>
482
484
  deprecated
@@ -540,7 +542,9 @@ function ArtifactHubChartCard({ chart, onSelect }: ArtifactHubChartCardProps) {
540
542
 
541
543
  {/* Name and org */}
542
544
  <div className="flex-1 min-w-0">
543
- <h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
545
+ <Tooltip content={chart.name} wrapperClassName="w-full">
546
+ <h4 className="text-sm font-medium text-theme-text-primary truncate">{chart.name}</h4>
547
+ </Tooltip>
544
548
  <div className="flex items-center gap-2 mt-0.5 text-xs text-theme-text-tertiary">
545
549
  <span className="flex items-center gap-1">
546
550
  <Building2 className="w-3 h-3" />
@@ -893,7 +893,7 @@ function ReviewStep({
893
893
  <div>
894
894
  <p className="text-sm font-medium text-amber-400">Installing from ArtifactHub</p>
895
895
  <p className="text-xs text-theme-text-secondary mt-1">
896
- This chart will be installed from: <code className="bg-theme-elevated px-1 rounded">{artifactHubRepoUrl || repo}</code>
896
+ This chart will be installed from: <code className="inline-code">{artifactHubRepoUrl || repo}</code>
897
897
  </p>
898
898
  </div>
899
899
  </div>
@@ -38,8 +38,8 @@ export function RoleGatedPanel({ min, feature, children }: RoleGatedPanelProps)
38
38
  Your role can't view {feature}
39
39
  </div>
40
40
  <div className="mt-1 max-w-md text-xs text-theme-text-secondary">
41
- You're signed in as <span className="font-mono text-theme-text-primary">{role ?? 'viewer'}</span>.
42
- This view requires <span className="font-mono text-theme-text-primary">{min}</span> or higher.
41
+ You're signed in as <span className="inline-code">{role ?? 'viewer'}</span>.
42
+ This view requires <span className="inline-code">{min}</span> or higher.
43
43
  Ask a {min} or owner if you need access.
44
44
  </div>
45
45
  </div>
@@ -52,7 +52,7 @@ function MetricsUnavailableHint({ platform, metricsServerAvailable }: { platform
52
52
  content={
53
53
  <div className="space-y-1">
54
54
  <div className="font-medium">How to fix</div>
55
- <div>{isPreInstalled ? hint : <>Install by running:<br /><code className="text-[10px] opacity-80">{hint}</code></>}</div>
55
+ <div>{isPreInstalled ? hint : <>Install by running:<br /><code className="inline-code text-[10px] opacity-80">{hint}</code></>}</div>
56
56
  </div>
57
57
  }
58
58
  position="bottom"
@@ -194,6 +194,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
194
194
  {hasProblems && (
195
195
  <ProblemsPanel
196
196
  problems={data.problems}
197
+ onNavigateToIssues={() => onNavigateToView('issues')}
197
198
  onResourceClick={onNavigateToResource}
198
199
  />
199
200
  )}
@@ -216,19 +217,29 @@ function BandItem({ children }: { children: ReactNode }) {
216
217
 
217
218
  interface ProblemsPanelProps {
218
219
  problems: DashboardResponse['problems']
220
+ onNavigateToIssues: () => void
219
221
  onResourceClick: (resource: SelectedResource) => void
220
222
  }
221
223
 
222
224
 
223
- function ProblemsPanel({ problems, onResourceClick }: ProblemsPanelProps) {
225
+ function ProblemsPanel({ problems, onNavigateToIssues, onResourceClick }: ProblemsPanelProps) {
224
226
  return (
225
227
  <div className="rounded-xl bg-theme-surface shadow-theme-sm flex flex-col lg:max-h-[calc(100vh-280px)] lg:sticky lg:top-0">
226
228
  <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50 shrink-0">
227
229
  <div className="flex items-center gap-2">
228
230
  <AlertTriangle className="w-4 h-4 text-red-500" />
229
- <span className="text-xs font-semibold uppercase tracking-wider text-red-500">Unhealthy Workloads</span>
231
+ <span className="text-xs font-semibold uppercase tracking-wider text-red-500">Active Issues</span>
232
+ </div>
233
+ <div className="flex items-center gap-2">
234
+ <button
235
+ type="button"
236
+ 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"
237
+ onClick={onNavigateToIssues}
238
+ >
239
+ View all
240
+ </button>
241
+ <span className="badge status-unhealthy rounded-full">{problems.length}</span>
230
242
  </div>
231
- <span className="badge status-unhealthy rounded-full">{problems.length}</span>
232
243
  </div>
233
244
  <div className="overflow-y-auto flex-1 min-h-0">
234
245
  <div className="divide-y divide-theme-border">
@@ -221,7 +221,7 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
221
221
  <div className="relative">
222
222
  <div className="flex items-center gap-3 bg-theme-base rounded-md px-3 py-2.5">
223
223
  <span className="badge text-purple-400 bg-purple-500/10">HTTP</span>
224
- <code className="text-sm font-mono text-theme-text-primary">{mcpUrl}</code>
224
+ <code className="inline-code text-sm">{mcpUrl}</code>
225
225
  </div>
226
226
  <CopyButton text={mcpUrl} />
227
227
  </div>
@@ -306,7 +306,7 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
306
306
  {MCP_TOOL_CATALOG.map((tool) => (
307
307
  <div key={tool.name} className="card-inner space-y-1.5">
308
308
  <div className="flex items-center gap-2">
309
- <code className="text-[11px] font-mono text-purple-400">{tool.name}</code>
309
+ <code className="inline-code text-[11px]">{tool.name}</code>
310
310
  {tool.write && (
311
311
  <span className="badge-sm bg-amber-500/10 text-amber-600 dark:text-amber-400" title="Write tool — annotated as destructive">
312
312
  write
@@ -317,8 +317,8 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
317
317
  {tool.params.length > 0 && (
318
318
  <div className="flex flex-wrap gap-1.5 pt-0.5">
319
319
  {tool.params.map((p) => (
320
- <span key={p.arg} className="badge-sm font-mono bg-theme-elevated text-theme-text-secondary" title={p.desc}>
321
- <span className="text-theme-text-secondary">{p.arg}</span>
320
+ <span key={p.arg} className="inline-code text-[11px]" title={p.desc}>
321
+ <span>{p.arg}</span>
322
322
  {p.required && <span className="text-red-400">*</span>}
323
323
  </span>
324
324
  ))}
@@ -0,0 +1,78 @@
1
+ import { useIssues } from '../../api/client'
2
+ import type { SelectedResource } from '../../types'
3
+ import { IssuesView, PaneLoader, type IssueResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { AlertTriangle, ArrowLeft } from 'lucide-react'
5
+
6
+ interface IssuesPaneProps {
7
+ namespaces: string[]
8
+ onBack: () => void
9
+ onNavigateToResource: (resource: SelectedResource) => void
10
+ }
11
+
12
+ // The per-cluster Issues surface. Renders the same shared triage queue
13
+ // (IssuesView) the Hub fleet view uses — single cluster here, so no cluster
14
+ // label and in-app (client-side) resource navigation. Classification +
15
+ // owner-grouping come pre-computed from radar's /api/issues
16
+ // (internal/issues.Compose → Classify → Group).
17
+ export function IssuesPane({ namespaces, onBack, onNavigateToResource }: IssuesPaneProps) {
18
+ const { data, isLoading, error } = useIssues(namespaces)
19
+
20
+ const onResourceClick = (ref: IssueResourceRef) =>
21
+ onNavigateToResource({ kind: ref.kind, namespace: ref.namespace ?? '', name: ref.name, group: ref.group ?? '' })
22
+
23
+ if (isLoading) {
24
+ return <PaneLoader label="Loading issues…" className="flex-1" />
25
+ }
26
+
27
+ if (error) {
28
+ return (
29
+ <div className="flex-1 flex items-center justify-center text-theme-text-secondary">
30
+ <p>Failed to load issues</p>
31
+ </div>
32
+ )
33
+ }
34
+
35
+ return (
36
+ <div className="flex-1 flex flex-col min-h-0 p-6 gap-6 overflow-auto">
37
+ <div className="flex items-center gap-4">
38
+ <button
39
+ onClick={onBack}
40
+ className="p-1.5 rounded-lg hover:bg-theme-hover transition-colors"
41
+ >
42
+ <ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
43
+ </button>
44
+ <div className="flex-1">
45
+ <div className="flex items-center gap-2">
46
+ <AlertTriangle className="w-5 h-5 text-theme-text-secondary" />
47
+ <h1 className="text-lg font-semibold text-theme-text-primary">Issues</h1>
48
+ </div>
49
+ <p className="text-sm text-theme-text-tertiary mt-1 ml-7">
50
+ Live cluster problems — crashes, scheduling failures, bad references — grouped by the resource they affect.
51
+ </p>
52
+ </div>
53
+ </div>
54
+
55
+ {/* Visibility honesty: when RBAC reads are incomplete, an empty queue may
56
+ mean "can't see" rather than "nothing broken" — say so up front so the
57
+ empty state isn't mistaken for a clean bill of health. */}
58
+ {data?.visibility?.impact && (
59
+ <div className="-mt-3 flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
60
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
61
+ <span>Limited visibility — {data.visibility.impact} Results may be incomplete.</span>
62
+ </div>
63
+ )}
64
+
65
+ {/* Truncation honesty: when more issues matched than were returned, say
66
+ so — don't present a capped list as the complete picture. */}
67
+ {data?.total_matched != null && data.total_matched > (data.issues?.length ?? 0) && (
68
+ <p className="-mt-3 text-xs text-theme-text-tertiary">
69
+ Showing {data.issues?.length ?? 0} of {data.total_matched} issues (capped) — narrow by namespace to see the rest.
70
+ </p>
71
+ )}
72
+
73
+ {/* anyData = the query resolved, i.e. the cluster is reachable; an empty
74
+ list then means "nothing broken" rather than "not connected". */}
75
+ <IssuesView issues={data?.issues ?? []} anyData={!!data} onResourceClick={onResourceClick} />
76
+ </div>
77
+ )
78
+ }
@@ -308,7 +308,7 @@ export function PortForwardButton({
308
308
  className="w-full px-3 py-2 text-left text-sm text-theme-text-primary hover:bg-theme-elevated flex items-center justify-between"
309
309
  >
310
310
  <span className="flex items-center gap-2 shrink-0">
311
- <code className="text-accent-text">{port.port}</code>
311
+ <code className="inline-code">{port.port}</code>
312
312
  <span className="text-theme-text-disabled">/{port.protocol || 'TCP'}</span>
313
313
  </span>
314
314
  {port.name && (
@@ -794,7 +794,7 @@ export function PortForwardPanel() {
794
794
  <Tooltip content="Click to change local port" delay={300} position="bottom" disabled={!isPanelOpen}>
795
795
  <code
796
796
  className={clsx(
797
- 'group/port text-xs bg-theme-base px-2 py-1 rounded text-accent-text transition-all inline-flex items-center gap-1',
797
+ 'inline-code group/port text-xs transition-all inline-flex items-center gap-1',
798
798
  changingPortId === session.id
799
799
  ? 'opacity-50'
800
800
  : 'cursor-pointer hover:ring-1 hover:ring-blue-500/50'