@skyhook-io/radar-app 1.7.0 → 1.8.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 (47) hide show
  1. package/package.json +9 -3
  2. package/src/App.tsx +150 -44
  3. package/src/api/client.ts +3 -3
  4. package/src/components/ConnectionErrorView.tsx +5 -2
  5. package/src/components/DebugOverlay.tsx +5 -3
  6. package/src/components/UserMenu.tsx +5 -2
  7. package/src/components/applications/ApplicationsView.tsx +1 -1
  8. package/src/components/audit/AuditSettingsDialog.tsx +14 -10
  9. package/src/components/audit/AuditView.tsx +3 -1
  10. package/src/components/compare/CompareViewRoute.tsx +13 -5
  11. package/src/components/compare/useCompareLauncher.tsx +6 -3
  12. package/src/components/cost/CostTrendChart.tsx +3 -3
  13. package/src/components/cost/CostView.tsx +14 -5
  14. package/src/components/helm/ChartBrowser.tsx +7 -5
  15. package/src/components/helm/HelmReleaseDrawer.tsx +25 -13
  16. package/src/components/helm/HelmView.tsx +3 -2
  17. package/src/components/helm/InstallWizard.tsx +4 -3
  18. package/src/components/helm/OwnedResources.tsx +24 -13
  19. package/src/components/helm/RevisionHistory.tsx +2 -1
  20. package/src/components/helm/ValuesViewer.tsx +4 -2
  21. package/src/components/home/ActivitySummary.tsx +4 -1
  22. package/src/components/home/ClusterHealthCard.tsx +21 -18
  23. package/src/components/home/HelmSummary.tsx +3 -1
  24. package/src/components/home/MCPSetupDialog.tsx +10 -4
  25. package/src/components/issues/IssuesPane.tsx +2 -2
  26. package/src/components/nav/PrimaryNavRail.tsx +5 -2
  27. package/src/components/portforward/PortForwardButton.tsx +14 -8
  28. package/src/components/portforward/PortForwardManager.tsx +70 -8
  29. package/src/components/resource/PrometheusChartsGrid.tsx +10 -3
  30. package/src/components/resource/RestartChart.tsx +6 -5
  31. package/src/components/resource/RightsizingStrip.tsx +4 -1
  32. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  33. package/src/components/resources/ImageFilesystemModal.tsx +16 -8
  34. package/src/components/resources/PodFilesystemModal.tsx +4 -2
  35. package/src/components/resources/ResourcesView.tsx +2 -0
  36. package/src/components/settings/MyPermissionsDialog.tsx +64 -4
  37. package/src/components/settings/SettingsDialog.tsx +371 -141
  38. package/src/components/shared/LargeClusterNamespacePicker.tsx +1 -1
  39. package/src/components/traffic/TrafficFilterSidebar.tsx +8 -43
  40. package/src/components/traffic/TrafficFlowList.tsx +13 -4
  41. package/src/components/traffic/TrafficGraph.tsx +37 -23
  42. package/src/components/traffic/TrafficView.tsx +7 -5
  43. package/src/components/ui/DiagnosticsOverlay.tsx +1 -1
  44. package/src/components/ui/Omnibar.tsx +0 -1
  45. package/src/components/workload/WorkloadView.tsx +32 -27
  46. package/src/context/NavCustomization.tsx +19 -5
  47. package/src/main.tsx +1 -1
@@ -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">
@@ -2,6 +2,7 @@ import { useRef, useEffect, useState, useCallback } from 'react'
2
2
  import { X, Copy, Check, Radio, Terminal, MessageSquare, Code2, ChevronRight, Pin } from 'lucide-react'
3
3
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
4
4
  import { MCP_TOOL_CATALOG } from './mcpToolCatalog'
5
+ import { Tooltip } from '../ui/Tooltip'
5
6
 
6
7
  interface MCPSetupDialogProps {
7
8
  open: boolean
@@ -19,13 +20,14 @@ function CopyButton({ text }: { text: string }) {
19
20
  }
20
21
 
21
22
  return (
23
+ <Tooltip content="Copy to clipboard" position="left" wrapperClassName="absolute top-2 right-2">
22
24
  <button
23
25
  onClick={handleCopy}
24
- className="absolute top-2 right-2 p-1.5 rounded-md bg-theme-elevated/50 hover:bg-theme-elevated text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
25
- title="Copy to clipboard"
26
+ className="p-1.5 rounded-md bg-theme-elevated/50 hover:bg-theme-elevated text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
26
27
  >
27
28
  {copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
28
29
  </button>
30
+ </Tooltip>
29
31
  )
30
32
  }
31
33
 
@@ -308,19 +310,23 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
308
310
  <div className="flex items-center gap-2">
309
311
  <code className="inline-code text-[11px]">{tool.name}</code>
310
312
  {tool.write && (
311
- <span className="badge-sm bg-amber-500/10 text-amber-600 dark:text-amber-400" title="Write tool — annotated as destructive">
313
+ <Tooltip content="Write tool — annotated as destructive">
314
+ <span className="badge-sm bg-amber-500/10 text-amber-600 dark:text-amber-400">
312
315
  write
313
316
  </span>
317
+ </Tooltip>
314
318
  )}
315
319
  </div>
316
320
  <p className="text-[11px] text-theme-text-tertiary leading-relaxed">{tool.desc}</p>
317
321
  {tool.params.length > 0 && (
318
322
  <div className="flex flex-wrap gap-1.5 pt-0.5">
319
323
  {tool.params.map((p) => (
320
- <span key={p.arg} className="inline-code text-[11px]" title={p.desc}>
324
+ <Tooltip key={p.arg} content={p.desc}>
325
+ <span className="inline-code text-[11px]">
321
326
  <span>{p.arg}</span>
322
327
  {p.required && <span className="text-red-400">*</span>}
323
328
  </span>
329
+ </Tooltip>
324
330
  ))}
325
331
  </div>
326
332
  )}
@@ -33,7 +33,7 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
33
33
  const { data, isLoading, error } = useIssues(namespaces)
34
34
  const [severityFilter, setSeverityFilter] = useState<Set<IssueSeverity>>(new Set())
35
35
 
36
- const allIssues = data?.issues ?? []
36
+ const allIssues = useMemo(() => data?.issues ?? [], [data])
37
37
  const totals = useMemo(() => {
38
38
  const t: Record<IssueSeverity, number> = { critical: 0, warning: 0 }
39
39
  for (const i of allIssues) t[i.severity] = (t[i.severity] ?? 0) + 1
@@ -44,7 +44,7 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
44
44
  const toggleSeverity = (s: IssueSeverity) =>
45
45
  setSeverityFilter((prev) => {
46
46
  const next = new Set(prev)
47
- next.has(s) ? next.delete(s) : next.add(s)
47
+ if (next.has(s)) next.delete(s); else next.add(s)
48
48
  return next
49
49
  })
50
50
 
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
3
3
  import { Home, Network, List, Clock, AlertTriangle, Package, GitBranch, Boxes, Activity, DollarSign, ShieldCheck, Settings, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
5
  import type { MainView } from '../../types'
6
+ import { Tooltip } from '../ui/Tooltip'
6
7
 
7
8
  // The views the rail can navigate to. Broader than k8s-ui's ExtendedMainView
8
9
  // (which omits 'applications') — it mirrors the navigable subset of App.tsx's
@@ -131,11 +132,11 @@ export function PrimaryNavRail({ activeView, onNavigate, pinned, onTogglePinned,
131
132
  forced slim (showPinToggle=false) — expanding there isn't available. */}
132
133
  {showPinToggle && (
133
134
  <div className="px-2 pb-2 pt-1 border-t border-theme-border/50">
135
+ <Tooltip content={pinned ? 'Collapse navigation' : 'Expand navigation'} position="right" wrapperClassName="!block w-full shrink-0">
134
136
  <button
135
137
  type="button"
136
138
  onClick={onTogglePinned}
137
139
  aria-label={pinned ? 'Collapse navigation' : 'Expand navigation'}
138
- title={pinned ? 'Collapse navigation' : 'Expand navigation'}
139
140
  className="group/pin relative flex h-9 w-full items-center rounded-md text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-secondary transition-colors"
140
141
  >
141
142
  <span className="flex w-10 shrink-0 items-center justify-center">
@@ -146,6 +147,7 @@ export function PrimaryNavRail({ activeView, onNavigate, pinned, onTogglePinned,
146
147
  would contradict the "Expand navigation" label. */}
147
148
  <span className={clsx('text-[13px] font-medium', !pinned && 'hidden')}>Collapse</span>
148
149
  </button>
150
+ </Tooltip>
149
151
  </div>
150
152
  )}
151
153
  </aside>
@@ -156,11 +158,11 @@ function BrandRow({ pinned, onNavigate }: { pinned: boolean; onNavigate: (view:
156
158
  // Clickable brand = secondary home affordance (logo→home convention). The
157
159
  // Home nav item below still carries the active state; the brand just navigates.
158
160
  return (
161
+ <Tooltip content="Home" position="right" wrapperClassName="!block w-full shrink-0">
159
162
  <button
160
163
  type="button"
161
164
  onClick={() => onNavigate('home')}
162
165
  aria-label="Radar — go to home"
163
- title="Home"
164
166
  // Height matches the top bar header (App.tsx — items-center + py-2 = 51px)
165
167
  // so the rail's brand divider and the header's bottom border form one line.
166
168
  className="flex h-[51px] w-full items-center border-b border-theme-border/50 shrink-0 transition-opacity hover:opacity-80"
@@ -181,6 +183,7 @@ function BrandRow({ pinned, onNavigate }: { pinned: boolean; onNavigate: (view:
181
183
  <span className="text-[9px] mt-0.5 tracking-wide uppercase text-theme-text-tertiary">by Skyhook</span>
182
184
  </span>
183
185
  </button>
186
+ </Tooltip>
184
187
  )
185
188
  }
186
189
 
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { useAvailablePorts, useClusterInfo, AvailablePort } from '../../api/client'
5
5
  import { useStartPortForward } from './PortForwardManager'
6
6
  import { validatePort } from '@skyhook-io/k8s-ui/utils/validators'
7
+ import { Tooltip } from '../ui/Tooltip'
7
8
 
8
9
  interface PortForwardButtonProps {
9
10
  type: 'pod' | 'service'
@@ -206,31 +207,32 @@ export function PortForwardButton({
206
207
  // If no ports available, show disabled button
207
208
  if (!isLoading && ports.length === 0) {
208
209
  return (
210
+ <Tooltip content="No ports available">
209
211
  <button
210
212
  disabled
211
213
  className={clsx(
212
- 'flex items-center gap-2 px-3 py-2 bg-theme-elevated text-theme-text-primary text-sm rounded-lg opacity-50 cursor-not-allowed',
214
+ 'flex items-center gap-2 px-3 py-2 bg-theme-elevated text-theme-text-primary text-sm rounded-lg opacity-50 cursor-not-allowed disabled:pointer-events-none',
213
215
  className
214
216
  )}
215
- title="No ports available"
216
217
  >
217
218
  <Plug className="w-4 h-4" />
218
219
  No Ports
219
220
  </button>
221
+ </Tooltip>
220
222
  )
221
223
  }
222
224
 
223
225
  // If only one port, forward directly on click (most common case)
224
226
  if (ports.length === 1) {
225
227
  return (
228
+ <Tooltip content={`Port forward to ${ports[0].port}`}>
226
229
  <button
227
230
  onClick={() => handlePortSelect(ports[0])}
228
231
  disabled={isPending}
229
232
  className={clsx(
230
- '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',
233
+ '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',
231
234
  className
232
235
  )}
233
- title={`Port forward to ${ports[0].port}`}
234
236
  >
235
237
  {isPending ? (
236
238
  <Loader2 className="w-4 h-4 animate-spin" />
@@ -239,6 +241,7 @@ export function PortForwardButton({
239
241
  )}
240
242
  Forward :{ports[0].port}
241
243
  </button>
244
+ </Tooltip>
242
245
  )
243
246
  }
244
247
 
@@ -269,6 +272,7 @@ export function PortForwardButton({
269
272
  <div className="px-3 py-2 border-b border-theme-border">
270
273
  <div className="text-xs text-theme-text-disabled mb-2">Listen on</div>
271
274
  <div className="flex gap-1">
275
+ <Tooltip content="Only accessible from this machine" wrapperClassName="flex-1">
272
276
  <button
273
277
  onClick={(e) => { e.stopPropagation(); setListenAddress('127.0.0.1') }}
274
278
  className={clsx(
@@ -277,11 +281,12 @@ export function PortForwardButton({
277
281
  ? 'btn-brand-toggle'
278
282
  : 'bg-theme-elevated text-theme-text-tertiary hover:text-theme-text-primary'
279
283
  )}
280
- title="Only accessible from this machine"
281
284
  >
282
285
  <Monitor className="w-3 h-3" />
283
286
  localhost
284
287
  </button>
288
+ </Tooltip>
289
+ <Tooltip content="Accessible from other machines on the network" wrapperClassName="flex-1">
285
290
  <button
286
291
  onClick={(e) => { e.stopPropagation(); setListenAddress('0.0.0.0') }}
287
292
  className={clsx(
@@ -290,11 +295,11 @@ export function PortForwardButton({
290
295
  ? 'bg-amber-600 text-white'
291
296
  : 'bg-theme-elevated text-theme-text-tertiary hover:text-theme-text-primary'
292
297
  )}
293
- title="Accessible from other machines on the network"
294
298
  >
295
299
  <Globe className="w-3 h-3" />
296
300
  all interfaces
297
301
  </button>
302
+ </Tooltip>
298
303
  </div>
299
304
  </div>
300
305
  )}
@@ -375,11 +380,11 @@ export function PortForwardInlineButton({
375
380
 
376
381
  return (
377
382
  <>
383
+ <Tooltip content={`Port forward ${port}`}>
378
384
  <button
379
385
  onClick={handleClick}
380
386
  disabled={disabled || isPending}
381
- 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"
382
- title={`Port forward ${port}`}
387
+ 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"
383
388
  >
384
389
  {port}/{protocol}
385
390
  {isPending ? (
@@ -388,6 +393,7 @@ export function PortForwardInlineButton({
388
393
  <Plug className="w-3 h-3" />
389
394
  )}
390
395
  </button>
396
+ </Tooltip>
391
397
  {dialogInfo && (
392
398
  <KubectlCommandDialog info={dialogInfo} onClose={() => setDialogInfo(null)} />
393
399
  )}
@@ -20,6 +20,7 @@ import {
20
20
  Globe,
21
21
  Monitor,
22
22
  PenLine,
23
+ RotateCw,
23
24
  } from 'lucide-react'
24
25
  import { clsx } from 'clsx'
25
26
  // CSS_EASE (the shared spring curve) is intentionally NOT used for this panel —
@@ -30,6 +31,7 @@ import { Tooltip } from '../ui/Tooltip'
30
31
  import { useToast } from '../ui/Toast'
31
32
  import { openExternal } from '../../utils/navigation'
32
33
  import { apiUrl } from '../../api/config'
34
+ import { apiFetch } from '../../api/client'
33
35
  import { pluralize } from '@skyhook-io/k8s-ui'
34
36
 
35
37
  // --- Types -------------------------------------------------------------------
@@ -86,7 +88,7 @@ function usePortForwardQuery() {
86
88
  return useQuery<PortForwardSession[]>({
87
89
  queryKey: ['portforwards'],
88
90
  queryFn: async () => {
89
- const res = await fetch(apiUrl('/portforwards'))
91
+ const res = await apiFetch(apiUrl('/portforwards'))
90
92
  if (!res.ok) throw new Error('Failed to fetch port forwards')
91
93
  return res.json()
92
94
  },
@@ -390,6 +392,9 @@ export function PortForwardPanel() {
390
392
  // without disabling all stop buttons (the old shared-mutation approach blocked
391
393
  // every row when any single stop was in-flight).
392
394
  const [stoppingIds, setStoppingIds] = useState<Set<string>>(() => new Set())
395
+ // Per-session retry tracking — same rationale as stoppingIds: multiple failed
396
+ // forwards can be retried independently without disabling every retry button.
397
+ const [retryingIds, setRetryingIds] = useState<Set<string>>(() => new Set())
393
398
  const queryClient = useQueryClient()
394
399
  const { showSuccess, showError } = useToast()
395
400
 
@@ -424,7 +429,7 @@ export function PortForwardPanel() {
424
429
  const stopPortForward = useCallback(async (id: string) => {
425
430
  setStoppingIds(prev => new Set(prev).add(id))
426
431
  try {
427
- const res = await fetch(apiUrl(`/portforwards/${id}`), { method: 'DELETE' })
432
+ const res = await apiFetch(apiUrl(`/portforwards/${id}`), { method: 'DELETE' })
428
433
  if (!res.ok) {
429
434
  const body = await res.json().catch(() => ({}))
430
435
  throw new Error(body.error || `Failed to stop port forward (HTTP ${res.status})`)
@@ -444,6 +449,48 @@ export function PortForwardPanel() {
444
449
  }
445
450
  }, [queryClient, showError])
446
451
 
452
+ // Recreate a failed forward. The errored session is already dead — there's no live
453
+ // forward to lose — so we drop the stale row FIRST, then recreate. Delete-first keeps
454
+ // the panel at exactly one row in every outcome (success → one running row; failure →
455
+ // one errored row), avoiding the orphaned-duplicate the reverse order would leave when
456
+ // the backend keeps a failed-start session in its map. A 404 means it was already
457
+ // cleared (e.g. context switch) — benign, proceed. Service-resolved sessions re-route
458
+ // through the service path via buildRecreateBody, so a retry after the backing pod was
459
+ // replaced re-resolves to a currently-running pod.
460
+ const retryPortForward = useCallback(async (session: PortForwardSession) => {
461
+ commitInteraction()
462
+ setRetryingIds(prev => new Set(prev).add(session.id))
463
+ try {
464
+ const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
465
+ if (!delRes.ok && delRes.status !== 404) {
466
+ const body = await delRes.json().catch(() => ({}))
467
+ throw new Error(body.error || `Failed to clear failed port forward (HTTP ${delRes.status})`)
468
+ }
469
+ const res = await apiFetch(apiUrl('/portforwards'), {
470
+ method: 'POST',
471
+ headers: { 'Content-Type': 'application/json' },
472
+ body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: session.listenAddress })),
473
+ })
474
+ if (!res.ok) {
475
+ const body = await res.json().catch(() => ({}))
476
+ throw new Error(body.error || `Failed to retry port forward (HTTP ${res.status})`)
477
+ }
478
+ queryClient.invalidateQueries({ queryKey: ['portforwards'] })
479
+ showSuccess('Port forward restarted', `Now listening on localhost:${session.localPort}`)
480
+ } catch (err) {
481
+ queryClient.invalidateQueries({ queryKey: ['portforwards'] })
482
+ const msg = err instanceof Error ? err.message : 'Failed to retry port forward'
483
+ showError('Failed to retry port forward', msg)
484
+ console.error('Failed to retry port forward:', err)
485
+ } finally {
486
+ setRetryingIds(prev => {
487
+ const next = new Set(prev)
488
+ next.delete(session.id)
489
+ return next
490
+ })
491
+ }
492
+ }, [commitInteraction, queryClient, showSuccess, showError])
493
+
447
494
  const toggleListenAddress = async (session: PortForwardSession) => {
448
495
  commitInteraction()
449
496
  const newAddress = session.listenAddress === '0.0.0.0' ? '127.0.0.1' : '0.0.0.0'
@@ -454,13 +501,13 @@ export function PortForwardPanel() {
454
501
  // apart from "original gone and recreate failed = data loss."
455
502
  let deleted = false
456
503
  try {
457
- const delRes = await fetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
504
+ const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
458
505
  if (!delRes.ok) {
459
506
  const body = await delRes.json().catch(() => ({}))
460
507
  throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
461
508
  }
462
509
  deleted = true
463
- const res = await fetch(apiUrl('/portforwards'), {
510
+ const res = await apiFetch(apiUrl('/portforwards'), {
464
511
  method: 'POST',
465
512
  headers: { 'Content-Type': 'application/json' },
466
513
  body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: newAddress })),
@@ -501,13 +548,13 @@ export function PortForwardPanel() {
501
548
  // apart from "original gone and recreate failed = data loss."
502
549
  let deleted = false
503
550
  try {
504
- const delRes = await fetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
551
+ const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
505
552
  if (!delRes.ok) {
506
553
  const body = await delRes.json().catch(() => ({}))
507
554
  throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
508
555
  }
509
556
  deleted = true
510
- const res = await fetch(apiUrl('/portforwards'), {
557
+ const res = await apiFetch(apiUrl('/portforwards'), {
511
558
  method: 'POST',
512
559
  headers: { 'Content-Type': 'application/json' },
513
560
  body: JSON.stringify(buildRecreateBody(session, { localPort: newPort, listenAddress: session.listenAddress })),
@@ -716,13 +763,28 @@ export function PortForwardPanel() {
716
763
  </button>
717
764
  </Tooltip>
718
765
  )}
766
+ {session.status === 'error' && (
767
+ <Tooltip content="Retry" delay={300} position="bottom" disabled={!isPanelOpen}>
768
+ <button
769
+ onClick={() => retryPortForward(session)}
770
+ disabled={retryingIds.has(session.id) || stoppingIds.has(session.id)}
771
+ className="p-1.5 text-theme-text-tertiary hover:text-green-400 hover:bg-theme-hover rounded disabled:opacity-50"
772
+ >
773
+ {retryingIds.has(session.id) ? (
774
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
775
+ ) : (
776
+ <RotateCw className="w-3.5 h-3.5" />
777
+ )}
778
+ </button>
779
+ </Tooltip>
780
+ )}
719
781
  <Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
720
782
  <button
721
783
  onClick={() => {
722
784
  commitInteraction()
723
785
  stopPortForward(session.id)
724
786
  }}
725
- disabled={stoppingIds.has(session.id)}
787
+ disabled={stoppingIds.has(session.id) || retryingIds.has(session.id)}
726
788
  className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
727
789
  >
728
790
  <Trash2 className="w-3.5 h-3.5" />
@@ -882,7 +944,7 @@ export function useStartPortForward() {
882
944
  localPort?: number
883
945
  listenAddress?: string // "127.0.0.1" (default) or "0.0.0.0"
884
946
  }) => {
885
- const res = await fetch(apiUrl('/portforwards'), {
947
+ const res = await apiFetch(apiUrl('/portforwards'), {
886
948
  method: 'POST',
887
949
  headers: { 'Content-Type': 'application/json' },
888
950
  body: JSON.stringify(req),
@@ -26,6 +26,7 @@ import {
26
26
  type CategoryDef,
27
27
  } from './PrometheusCharts'
28
28
  import { RestartEventLane } from './RestartChart'
29
+ import { Tooltip } from '../ui/Tooltip'
29
30
 
30
31
  // Used when MetricsTabContent is in expanded (full-screen) mode. Drawer mode
31
32
  // uses the single-chart tabbed `PrometheusCharts` instead — drawer width
@@ -288,7 +289,11 @@ function WorkloadHealthBadge({ kind, namespace, name }: { kind: string; namespac
288
289
  // silently render as fine while we have no signal to display.
289
290
  if (error && !data) {
290
291
  const msg = error instanceof Error ? error.message : String(error)
291
- return <span className={`badge badge-sm ${SEVERITY_BADGE.neutral}`} title={`Health check failed: ${msg}`}>Health unknown</span>
292
+ return (
293
+ <Tooltip content={`Health check failed: ${msg}`}>
294
+ <span className={`badge badge-sm ${SEVERITY_BADGE.neutral}`}>Health unknown</span>
295
+ </Tooltip>
296
+ )
292
297
  }
293
298
  if (!data?.sampleAvailable || data.rows.length === 0) return null
294
299
 
@@ -311,7 +316,7 @@ function PanelLoading() {
311
316
  return (
312
317
  <div className="flex items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs">
313
318
  <Loader2 className="w-4 h-4 animate-spin mr-2" />
314
- Loading...
319
+ Loading
315
320
  </div>
316
321
  )
317
322
  }
@@ -320,7 +325,9 @@ function PanelError({ message }: { message: string }) {
320
325
  return (
321
326
  <div className={`flex flex-col items-center justify-center h-full min-h-[160px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}>
322
327
  Query failed
323
- <span className="text-theme-text-quaternary mt-0.5 line-clamp-2" title={message}>{message}</span>
328
+ <Tooltip content={message} wrapperClassName="!block w-full">
329
+ <span className="text-theme-text-quaternary mt-0.5 line-clamp-2">{message}</span>
330
+ </Tooltip>
324
331
  </div>
325
332
  )
326
333
  }
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo } from 'react'
2
2
  import { AlertCircle } from 'lucide-react'
3
3
  import { usePrometheusResourceMetrics, usePrometheusStatus, type PrometheusSeries, type PrometheusTimeRange } from '../../api/client'
4
+ import { Tooltip } from '../ui/Tooltip'
4
5
 
5
6
  /**
6
7
  * RestartEventLane — vertical markers at each restart event, on a dedicated
@@ -58,14 +59,14 @@ export function RestartEventLane({ kind, namespace, name, range = '1h' }: {
58
59
  {restarts.map((r, i) => {
59
60
  const left = `${Math.max(0, Math.min(100, ((r.timestamp - windowStart) / span) * 100))}%`
60
61
  return (
61
- <div
62
+ <Tooltip
62
63
  key={i}
63
- className="absolute top-0 h-full w-px bg-amber-500/80"
64
- style={{ left }}
65
- title={`${new Date(r.timestamp * 1000).toLocaleString()} · ${r.label}${r.value > 1 ? ` ×${r.value}` : ''}`}
64
+ content={`${new Date(r.timestamp * 1000).toLocaleString()} · ${r.label}${r.value > 1 ? ` ×${r.value}` : ''}`}
65
+ wrapperClassName="absolute top-0 h-full w-px bg-amber-500/80"
66
+ wrapperStyle={{ left }}
66
67
  >
67
68
  <div className="absolute -top-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-amber-500" />
68
- </div>
69
+ </Tooltip>
69
70
  )
70
71
  })}
71
72
  </div>
@@ -1,6 +1,7 @@
1
1
  import { ArrowRight, Check, Info, AlertTriangle } from 'lucide-react'
2
2
  import { SEVERITY_TEXT, SEVERITY_BADGE, type Severity } from '@skyhook-io/k8s-ui/utils/badge-colors'
3
3
  import { usePrometheusRightsizing, usePrometheusStatus, type RightsizingTone, type RightsizingRow } from '../../api/client'
4
+ import { Tooltip } from '../ui/Tooltip'
4
5
 
5
6
  const RIGHTSIZING_KINDS = new Set(['Deployment', 'StatefulSet', 'DaemonSet'])
6
7
 
@@ -38,7 +39,9 @@ export function RightsizingStrip({ kind, namespace, name }: {
38
39
  <header className="flex items-center justify-between mb-1">
39
40
  <h3 className="text-sm font-medium text-theme-text-primary">Right-sizing</h3>
40
41
  </header>
41
- <p className="text-xs text-theme-text-tertiary" title={msg}>Right-sizing unavailable — Prometheus query failed.</p>
42
+ <Tooltip content={msg}>
43
+ <p className="text-xs text-theme-text-tertiary">Right-sizing unavailable — Prometheus query failed.</p>
44
+ </Tooltip>
42
45
  </section>
43
46
  )
44
47
  }
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { useState, useCallback } from 'react'
5
5
  import type { TopologyNode, NodeKind, HealthStatus } from '../../types'
6
6
  import { getKindBadgeBordered, healthToSeverity, SEVERITY_BADGE_BORDERED } from '../../utils/badge-colors'
7
+ import { Tooltip } from '../ui/Tooltip'
7
8
 
8
9
  interface ResourceDrawerProps {
9
10
  node: TopologyNode
@@ -146,10 +147,10 @@ export const ResourceDrawer = memo(function ResourceDrawer({
146
147
  <h2 className="text-lg font-semibold text-theme-text-primary truncate">
147
148
  {node.name}
148
149
  </h2>
150
+ <Tooltip content="Copy name">
149
151
  <button
150
152
  onClick={copyName}
151
153
  className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
152
- title="Copy name"
153
154
  >
154
155
  {copied ? (
155
156
  <Check className="w-4 h-4 text-green-400" />
@@ -157,6 +158,7 @@ export const ResourceDrawer = memo(function ResourceDrawer({
157
158
  <Copy className="w-4 h-4" />
158
159
  )}
159
160
  </button>
161
+ </Tooltip>
160
162
  </div>
161
163
  </div>
162
164
  <button
@@ -7,6 +7,7 @@ import { useImageMetadata, ApiError } from '../../api/client'
7
7
  import type { FileNode, ImageFilesystem } from '../../types'
8
8
  import { formatBytes } from '../../utils/format'
9
9
  import { downloadBlob, filterTree } from './file-browser-utils'
10
+ import { Tooltip } from '../ui/Tooltip'
10
11
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
11
12
 
12
13
  // Manual fetch function for filesystem (not a hook - gives us full control)
@@ -141,9 +142,11 @@ export function ImageFilesystemModal({
141
142
  <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
142
143
  <div className="flex-1 min-w-0">
143
144
  <h3 className="text-lg font-semibold text-theme-text-primary">Image Filesystem</h3>
144
- <p className="text-sm text-theme-text-secondary truncate mt-0.5" title={image}>
145
+ <Tooltip content={image} wrapperClassName="!block w-full">
146
+ <p className="text-sm text-theme-text-secondary truncate mt-0.5">
145
147
  {image}
146
148
  </p>
149
+ </Tooltip>
147
150
  {(displayFilesystem?.platform || metadata?.platform) && (
148
151
  <p className="text-xs text-theme-text-tertiary mt-1">
149
152
  Platform: {displayFilesystem?.platform || metadata?.platform}
@@ -245,9 +248,11 @@ export function ImageFilesystemModal({
245
248
  <span>{formatBytes(displayFilesystem.totalSize)}</span>
246
249
  {displayFilesystem.layers && <span>{displayFilesystem.layers.length} layers</span>}
247
250
  {displayFilesystem.digest && (
248
- <span className="truncate" title={displayFilesystem.digest}>
251
+ <Tooltip content={displayFilesystem.digest} wrapperClassName="min-w-0">
252
+ <span className="truncate">
249
253
  Digest: {displayFilesystem.digest.substring(0, 20)}...
250
254
  </span>
255
+ </Tooltip>
251
256
  )}
252
257
  </>
253
258
  )}
@@ -362,10 +367,10 @@ function DownloadConfirmation({ metadata, onConfirm, onCancel }: DownloadConfirm
362
367
  <pre className="bg-theme-elevated rounded p-3 text-xs text-theme-text-primary overflow-x-auto font-mono">
363
368
  {authCommand}
364
369
  </pre>
370
+ <Tooltip content="Copy to clipboard" position="left" wrapperClassName="absolute top-2 right-2">
365
371
  <button
366
372
  onClick={handleCopy}
367
- className="absolute top-2 right-2 p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-base rounded transition-colors"
368
- title="Copy to clipboard"
373
+ className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-base rounded transition-colors"
369
374
  >
370
375
  {copied ? (
371
376
  <Check className="w-4 h-4 text-green-400" />
@@ -373,6 +378,7 @@ function DownloadConfirmation({ metadata, onConfirm, onCancel }: DownloadConfirm
373
378
  <Copy className="w-4 h-4" />
374
379
  )}
375
380
  </button>
381
+ </Tooltip>
376
382
  </div>
377
383
  </div>
378
384
  )}
@@ -461,10 +467,10 @@ function AuthenticationHelp({ image, registryType, onRetry }: AuthenticationHelp
461
467
  <pre className="bg-theme-elevated rounded p-3 text-xs text-theme-text-primary overflow-x-auto font-mono">
462
468
  {authCommand}
463
469
  </pre>
470
+ <Tooltip content="Copy to clipboard" position="left" wrapperClassName="absolute top-2 right-2">
464
471
  <button
465
472
  onClick={handleCopy}
466
- className="absolute top-2 right-2 p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-base rounded transition-colors"
467
- title="Copy to clipboard"
473
+ className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-base rounded transition-colors"
468
474
  >
469
475
  {copied ? (
470
476
  <Check className="w-4 h-4 text-green-400" />
@@ -472,6 +478,7 @@ function AuthenticationHelp({ image, registryType, onRetry }: AuthenticationHelp
472
478
  <Copy className="w-4 h-4" />
473
479
  )}
474
480
  </button>
481
+ </Tooltip>
475
482
  </div>
476
483
  </div>
477
484
  )}
@@ -709,11 +716,11 @@ function FileTreeNode({ node, depth, defaultExpanded = true, image, namespace, p
709
716
  )}
710
717
 
711
718
  {isFile && (
719
+ <Tooltip content="Download file" wrapperClassName="ml-1">
712
720
  <button
713
721
  onClick={handleDownload}
714
722
  disabled={downloading}
715
- className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-theme-elevated rounded ml-1 disabled:opacity-50"
716
- title="Download file"
723
+ className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-theme-elevated rounded disabled:opacity-50 disabled:pointer-events-none"
717
724
  >
718
725
  {downloading ? (
719
726
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -721,6 +728,7 @@ function FileTreeNode({ node, depth, defaultExpanded = true, image, namespace, p
721
728
  <Download className="w-3.5 h-3.5" />
722
729
  )}
723
730
  </button>
731
+ </Tooltip>
724
732
  )}
725
733
  </div>
726
734
 
@@ -7,6 +7,7 @@ import type { FileNode } from '../../types'
7
7
  import { formatBytes } from '../../utils/format'
8
8
  import { downloadBlob, filterTree } from './file-browser-utils'
9
9
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
10
+ import { Tooltip } from '../ui/Tooltip'
10
11
 
11
12
  interface PodFilesystem {
12
13
  root: FileNode
@@ -384,11 +385,11 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
384
385
  )}
385
386
 
386
387
  {isDownloadable && (
388
+ <Tooltip content="Download file" wrapperClassName="ml-1">
387
389
  <button
388
390
  onClick={handleDownload}
389
391
  disabled={downloading}
390
- className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-theme-elevated rounded ml-1 disabled:opacity-50"
391
- title="Download file"
392
+ className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-theme-elevated rounded disabled:opacity-50 disabled:pointer-events-none"
392
393
  >
393
394
  {downloading ? (
394
395
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -396,6 +397,7 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
396
397
  <Download className="w-3.5 h-3.5" />
397
398
  )}
398
399
  </button>
400
+ </Tooltip>
399
401
  )}
400
402
  </div>
401
403
  )