@skyhook-io/radar-app 1.8.6 → 1.8.7

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 (75) hide show
  1. package/README.md +7 -1
  2. package/package.json +11 -9
  3. package/src/App.tsx +221 -181
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.delta.test.ts +89 -0
  6. package/src/api/client.deltaSync.test.ts +216 -0
  7. package/src/api/client.ts +262 -34
  8. package/src/api/diagnose.ts +288 -0
  9. package/src/api/timelineSource.test.ts +217 -0
  10. package/src/api/timelineSource.ts +580 -0
  11. package/src/components/ConnectionErrorView.tsx +174 -70
  12. package/src/components/ContextSwitcher.tsx +13 -5
  13. package/src/components/applications/ApplicationsView.tsx +327 -26
  14. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  15. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  16. package/src/components/diagnose/AISettings.tsx +121 -0
  17. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  18. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  19. package/src/components/diagnose/Home.tsx +163 -0
  20. package/src/components/diagnose/InvestigationView.tsx +604 -0
  21. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  22. package/src/components/diagnose/launch.ts +65 -0
  23. package/src/components/diagnose/parts.tsx +1689 -0
  24. package/src/components/dock/BottomDock.tsx +2 -3
  25. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  26. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  27. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  28. package/src/components/execution/batch-run-actions.test.ts +48 -0
  29. package/src/components/execution/batch-run-actions.ts +24 -0
  30. package/src/components/execution/batch-timeline.test.ts +57 -0
  31. package/src/components/execution/batch-timeline.ts +46 -0
  32. package/src/components/execution/execution-definition.test.ts +208 -0
  33. package/src/components/execution/execution-definition.ts +245 -0
  34. package/src/components/helm/ChartBrowser.tsx +2 -3
  35. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  36. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  37. package/src/components/helm/HelmView.tsx +2 -3
  38. package/src/components/helm/InstallWizard.tsx +3 -5
  39. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  40. package/src/components/helm/ValuesDiffPreview.tsx +15 -4
  41. package/src/components/home/HomeView.tsx +17 -15
  42. package/src/components/home/MCPSetupDialog.tsx +1 -1
  43. package/src/components/home/mcpToolCatalog.ts +2 -2
  44. package/src/components/issues/IssuesPane.tsx +9 -1
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  46. package/src/components/portforward/PortForwardButton.tsx +2 -2
  47. package/src/components/portforward/PortForwardManager.tsx +19 -14
  48. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  49. package/src/components/resource/RightsizingStrip.tsx +0 -3
  50. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  51. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  52. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  53. package/src/components/resources/ResourcesView.tsx +13 -4
  54. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  55. package/src/components/resources/renderers/index.ts +1 -0
  56. package/src/components/settings/SettingsDialog.tsx +80 -23
  57. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  58. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  59. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  60. package/src/components/timeline/TimelineList.tsx +51 -10
  61. package/src/components/timeline/TimelineView.tsx +701 -15
  62. package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
  63. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  64. package/src/components/traffic/TrafficView.tsx +8 -4
  65. package/src/components/ui/CommandPalette.tsx +2 -2
  66. package/src/components/workload/WorkloadView.tsx +319 -35
  67. package/src/context/ConnectionContext.tsx +109 -11
  68. package/src/context/DiagnoseCustomization.tsx +42 -0
  69. package/src/context/TimelineSource.tsx +50 -0
  70. package/src/hooks/useClusterLoadState.ts +73 -0
  71. package/src/hooks/useEventSource.ts +6 -0
  72. package/src/index.css +157 -0
  73. package/src/index.ts +12 -0
  74. package/src/types/clusterLoadState.ts +33 -0
  75. package/src/utils/navigation.ts +10 -0
@@ -3,12 +3,40 @@ import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
3
3
  import { X, Plus, Trash2, Link2, AlertTriangle } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
5
  import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo } from '../../api/client'
6
+ import { Input } from '@skyhook-io/k8s-ui'
7
+ import type { UpgradeInfo } from '../../types'
6
8
 
7
9
  interface TrackChartSourceDialogProps {
8
10
  open: boolean
9
11
  onClose: () => void
10
12
  /** Chart name of the release this was opened from, for the example prompt. */
11
13
  chartName?: string
14
+ sourceIssue?: UpgradeInfo['sourceIssue']
15
+ sourceError?: string
16
+ }
17
+
18
+ function getSourceIssueCopy(sourceIssue: UpgradeInfo['sourceIssue'], sourceError?: string) {
19
+ switch (sourceIssue) {
20
+ case 'repo_index_error':
21
+ return {
22
+ title: 'A Helm repo index failed',
23
+ body: 'Fix, refresh, or remove the broken Helm repo index. If this release came from OCI, add its registry prefix and Radar will check OCI before reporting that repo error.',
24
+ }
25
+ case 'ambiguous_repository':
26
+ return {
27
+ title: 'Multiple Helm repos match',
28
+ body: 'Radar will not guess between classic repos. Adding an OCI prefix will not enable direct upgrades while those repos remain ambiguous.',
29
+ }
30
+ case 'untracked':
31
+ return {
32
+ title: 'Source not tracked',
33
+ body: 'Helm does not record the install source. Add the OCI prefix that contains this chart.',
34
+ }
35
+ default:
36
+ return sourceError
37
+ ? { title: 'Source unresolved', body: sourceError }
38
+ : undefined
39
+ }
12
40
  }
13
41
 
14
42
  // TrackChartSourceDialog lets the user register an OCI chart-source prefix — the
@@ -16,7 +44,7 @@ interface TrackChartSourceDialogProps {
16
44
  // installed from, so for charts published to an OCI registry (and not managed by
17
45
  // GitOps) Radar can only track upgrades once the user declares where they live.
18
46
  // Registering a registry/org prefix lets Radar probe "<prefix>/<chartName>".
19
- export function TrackChartSourceDialog({ open, onClose, chartName }: TrackChartSourceDialogProps) {
47
+ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue, sourceError }: TrackChartSourceDialogProps) {
20
48
  const [value, setValue] = useState('')
21
49
  const { data: sources } = useHelmOCISources()
22
50
  const { data: clusterInfo } = useClusterInfo()
@@ -30,6 +58,11 @@ export function TrackChartSourceDialog({ open, onClose, chartName }: TrackChartS
30
58
 
31
59
  const trimmed = value.trim()
32
60
  const invalid = trimmed !== '' && !trimmed.startsWith('oci://')
61
+ const normalizedInput = trimmed.replace(/\/+$/, '')
62
+ const normalizedChartName = chartName?.trim().replace(/^\/+|\/+$/g, '') ?? ''
63
+ const probedRef = normalizedInput && normalizedChartName ? `${normalizedInput}/${normalizedChartName}` : ''
64
+ const looksLikeFullChartRef = Boolean(normalizedInput && normalizedChartName && normalizedInput.endsWith(`/${normalizedChartName}`))
65
+ const sourceIssueCopy = getSourceIssueCopy(sourceIssue, sourceError)
33
66
 
34
67
  const handleAdd = () => {
35
68
  if (!trimmed || invalid) return
@@ -58,13 +91,22 @@ export function TrackChartSourceDialog({ open, onClose, chartName }: TrackChartS
58
91
  </div>
59
92
 
60
93
  <div className="p-4 space-y-4">
94
+ {sourceIssueCopy && (
95
+ <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-sm">
96
+ <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-theme-text-secondary" />
97
+ <div>
98
+ <p className="font-medium text-theme-text-primary">{sourceIssueCopy.title}</p>
99
+ <p className="text-theme-text-secondary">{sourceIssueCopy.body}</p>
100
+ </div>
101
+ </div>
102
+ )}
103
+
61
104
  <div>
62
105
  <label className="block text-sm font-medium text-theme-text-secondary mb-2">
63
106
  OCI registry prefix
64
107
  </label>
65
108
  <div className="flex gap-2">
66
- <input
67
- type="text"
109
+ <Input
68
110
  value={value}
69
111
  onChange={(e) => setValue(e.target.value)}
70
112
  onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
@@ -87,8 +129,10 @@ export function TrackChartSourceDialog({ open, onClose, chartName }: TrackChartS
87
129
  <p className="mt-1 text-xs text-theme-text-tertiary">
88
130
  {invalid
89
131
  ? 'Must be an oci:// reference.'
132
+ : looksLikeFullChartRef
133
+ ? `This looks like a full chart ref. Radar would probe "${probedRef}"; remove the final "/${normalizedChartName}" if "${normalizedChartName}" is the chart name.`
90
134
  : chartName
91
- ? `Radar will look for "${chartName}" under this prefix (and any others below).`
135
+ ? `Radar will probe "${probedRef || `<prefix>/${normalizedChartName}`}".`
92
136
  : 'Radar probes <prefix>/<chartName> for each untracked release.'}
93
137
  </p>
94
138
  </div>
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { X, Play, Loader2, FileText, AlertTriangle } from 'lucide-react'
3
4
  import { clsx } from 'clsx'
4
5
  import type { ValuesPreviewResponse } from '../../types'
@@ -8,6 +9,8 @@ interface ValuesDiffPreviewProps {
8
9
  onClose: () => void
9
10
  onApply: () => void
10
11
  isApplying: boolean
12
+ title?: string
13
+ applyLabel?: string
11
14
  }
12
15
 
13
16
  export function ValuesDiffPreview({
@@ -15,6 +18,8 @@ export function ValuesDiffPreview({
15
18
  onClose,
16
19
  onApply,
17
20
  isApplying,
21
+ title = 'Preview Changes',
22
+ applyLabel = 'Apply Changes',
18
23
  }: ValuesDiffPreviewProps) {
19
24
  const dialogRef = useRef<HTMLDivElement>(null)
20
25
 
@@ -40,7 +45,10 @@ export function ValuesDiffPreview({
40
45
  const hasChanges = previewData.manifestDiff.trim().length > 0 &&
41
46
  previewData.manifestDiff.split('\n').some(line => line.startsWith('+') || line.startsWith('-'))
42
47
 
43
- return (
48
+ // Portal to body so the modal escapes any CSS-transformed ancestor (the release
49
+ // drawer, the upgrade ConfirmDialog) and layers on top rather than being trapped
50
+ // in a lower stacking context.
51
+ return createPortal(
44
52
  <div className="fixed inset-0 z-50 flex items-center justify-center">
45
53
  {/* Backdrop */}
46
54
  <div
@@ -51,6 +59,8 @@ export function ValuesDiffPreview({
51
59
  {/* Dialog */}
52
60
  <div
53
61
  ref={dialogRef}
62
+ role="dialog"
63
+ aria-modal="true"
54
64
  tabIndex={-1}
55
65
  className="relative dialog max-w-4xl w-full mx-4 max-h-[85vh] flex flex-col outline-none"
56
66
  >
@@ -58,7 +68,7 @@ export function ValuesDiffPreview({
58
68
  <div className="flex items-center justify-between px-4 py-3 border-b border-theme-border shrink-0">
59
69
  <div className="flex items-center gap-2">
60
70
  <FileText className="w-5 h-5 text-blue-400" />
61
- <h3 className="text-lg font-semibold text-theme-text-primary">Preview Changes</h3>
71
+ <h3 className="text-lg font-semibold text-theme-text-primary">{title}</h3>
62
72
  </div>
63
73
  <button
64
74
  onClick={onClose}
@@ -120,12 +130,13 @@ export function ValuesDiffPreview({
120
130
  ) : (
121
131
  <Play className="w-4 h-4" />
122
132
  )}
123
- Apply Changes
133
+ {applyLabel}
124
134
  </button>
125
135
  </div>
126
136
  </div>
127
137
  </div>
128
- </div>
138
+ </div>,
139
+ document.body
129
140
  )
130
141
  }
131
142
 
@@ -1,6 +1,7 @@
1
1
  import { useMemo, type ReactNode } from 'react'
2
2
  import { useDashboard, useDashboardCRDs, useDashboardHelm, useIssues, type IssuesResponse } from '../../api/client'
3
3
  import { useConnection } from '../../context/ConnectionContext'
4
+ import type { ClusterLoadState } from '../../types/clusterLoadState'
4
5
  import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
5
6
  import { TopologyPreview } from './TopologyPreview'
6
7
  import { HelmSummary } from './HelmSummary'
@@ -10,6 +11,7 @@ import { CertificateHealthCard } from './CertificateHealthCard'
10
11
  import { NetworkPolicyCoverageCard } from './NetworkPolicyCoverageCard'
11
12
  import { CostCard } from './CostCard'
12
13
  import { GitOpsControllersCard } from './GitOpsControllersCard'
14
+ import { Tooltip } from '../ui/Tooltip'
13
15
  import {
14
16
  AuditCard,
15
17
  FreshnessControl,
@@ -17,6 +19,7 @@ import {
17
19
  StatusDot,
18
20
  categoryLabel,
19
21
  groupLabel,
22
+ issueTiming,
20
23
  subjectRef,
21
24
  type Issue,
22
25
  } from '@skyhook-io/k8s-ui'
@@ -31,6 +34,7 @@ interface HomeViewProps {
31
34
  onNavigateToView: (view: ExtendedMainView, params?: Record<string, string>) => void
32
35
  onNavigateToResourceKind: (kind: string, group?: string, filters?: Record<string, string[]>) => void
33
36
  onNavigateToResource: (resource: SelectedResource) => void
37
+ fallbackClusterLoadState?: ClusterLoadState
34
38
  /**
35
39
  * Optional override for the Certificate Health card's click. When an embedded
36
40
  * host (Radar Cloud) takes Certs over with its own fleet page, it passes this
@@ -40,7 +44,7 @@ interface HomeViewProps {
40
44
  onNavigateToCerts?: () => void
41
45
  }
42
46
 
43
- export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts }: HomeViewProps) {
47
+ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts }: HomeViewProps) {
44
48
  const { data, isLoading, error, dataUpdatedAt, refetch } = useDashboard(namespaces)
45
49
  const { connection } = useConnection()
46
50
  const { data: issuesData, isLoading: issuesLoading, isFetching: issuesFetching, error: issuesError } = useIssues(namespaces)
@@ -67,7 +71,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
67
71
  const { data: helmData } = useDashboardHelm(namespaces)
68
72
 
69
73
  if (isLoading) {
70
- return <PaneLoader label="Loading dashboard…" className="flex-1" />
74
+ return <PaneLoader label="Loading dashboard…" className="flex-1 min-h-0" />
71
75
  }
72
76
 
73
77
  if (error || !data) {
@@ -94,19 +98,13 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
94
98
  )
95
99
  }
96
100
 
97
- const stillLoading = data.deferredLoading || (data.partialData && data.partialData.length > 0)
98
-
99
101
  return (
100
102
  <div className="flex-1 overflow-y-auto">
101
103
  <div className="max-w-[1600px] mx-auto px-6 py-6 space-y-6">
102
- {stillLoading && (
103
- <div className="flex items-center gap-2 text-xs text-theme-text-tertiary">
104
- <Loader2 className="w-3 h-3 animate-spin" />
105
- <span>
106
- {data.partialData && data.partialData.length > 0
107
- ? `Still loading: ${data.partialData.join(', ')}`
108
- : 'Loading remaining resources…'}
109
- </span>
104
+ {fallbackClusterLoadState?.loading && (
105
+ <div className="flex items-center gap-2 text-sm text-theme-text-tertiary">
106
+ <Loader2 className="h-4 w-4 animate-spin" />
107
+ <span>{fallbackClusterLoadState.message}</span>
110
108
  </div>
111
109
  )}
112
110
  {/* Row 1: Cluster Health Card (combined health + resource counts) */}
@@ -360,7 +358,7 @@ function ProblemsPanel({
360
358
  {issues.map((issue) => {
361
359
  const ref = subjectRef(issue)
362
360
  const age = issue.first_seen ? formatCompactAge(issue.first_seen) : ''
363
- const startedAtDeploy = issue.issue_timing === 'started_at_resource_creation'
361
+ const timing = issueTiming(issue)
364
362
 
365
363
  return (
366
364
  <button
@@ -378,10 +376,14 @@ function ProblemsPanel({
378
376
  <div className="flex items-center gap-1.5">
379
377
  <span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{issue.kind}</span>
380
378
  <span className="text-xs text-theme-text-primary truncate font-medium">{issue.name}</span>
381
- {(age || startedAtDeploy) && (
379
+ {(age || timing) && (
382
380
  <span className="ml-auto flex shrink-0 items-center gap-1">
383
381
  {age && <span className="text-[10px] text-theme-text-tertiary tabular-nums">{age}</span>}
384
- {startedAtDeploy && <span className="badge-sm text-[10px] text-theme-text-secondary">since deploy</span>}
382
+ {timing && (
383
+ <Tooltip content={timing.tooltip} delay={100}>
384
+ <span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
385
+ </Tooltip>
386
+ )}
385
387
  </span>
386
388
  )}
387
389
  </div>
@@ -204,7 +204,7 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
204
204
  <a href="https://modelcontextprotocol.io" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:text-purple-300 underline underline-offset-2">
205
205
  Model Context Protocol
206
206
  </a>{' '}
207
- (MCP) server that lets AI assistants query your cluster through Radar.
207
+ (MCP) server that lets AI agents query your cluster through Radar.
208
208
  Unlike raw kubectl access, Radar gives your AI pre-processed, enriched data —
209
209
  topology graphs, health assessments, deduplicated events, filtered logs — so it
210
210
  can understand your cluster state quickly without burning through context on
@@ -237,9 +237,9 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
237
237
  },
238
238
  {
239
239
  name: 'get_workload_logs',
240
- desc: 'Aggregated, filtered logs across all pods of a workload (Deployment, StatefulSet, or DaemonSet) — collected concurrently, filtered for errors/warnings, and deduplicated.',
240
+ desc: 'Aggregated, filtered logs across all pods of a workload (Deployment, StatefulSet, DaemonSet, Job, or Argo Workflow) — collected concurrently, filtered for errors/warnings, and deduplicated.',
241
241
  params: [
242
- { arg: 'kind', desc: 'deployment (default), statefulset, or daemonset' },
242
+ { arg: 'kind', desc: 'deployment (default), statefulset, daemonset, job, or workflow' },
243
243
  { arg: 'namespace', required: true, desc: 'workload namespace' },
244
244
  { arg: 'name', required: true, desc: 'workload name' },
245
245
  { arg: 'container', desc: 'specific container (defaults to all)' },
@@ -15,6 +15,7 @@ import {
15
15
  type SummaryTone,
16
16
  } from '@skyhook-io/k8s-ui'
17
17
  import { AlertTriangle } from 'lucide-react'
18
+ import { IssueDiagnoseButton } from '../diagnose/LocalDiagnoseAction'
18
19
 
19
20
  const SEVERITY_TONE: Record<IssueSeverity, SummaryTone> = { critical: 'error', warning: 'warning' }
20
21
 
@@ -136,7 +137,14 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
136
137
  ) : (
137
138
  /* anyData = the query resolved, i.e. the cluster is reachable; an empty
138
139
  list then means "nothing broken" rather than "not connected". */
139
- <IssuesView issues={shown} anyData={!!data} onResourceClick={onResourceClick} />
140
+ <IssuesView
141
+ issues={shown}
142
+ anyData={!!data}
143
+ onResourceClick={onResourceClick}
144
+ renderActions={({ issue }) => (
145
+ <IssueDiagnoseButton kind={issue.kind} namespace={issue.namespace ?? ''} name={issue.name} />
146
+ )}
147
+ />
140
148
  )}
141
149
  </div>
142
150
  )
@@ -0,0 +1,135 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { Loader2, Terminal } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { useWorkloadRuns, type WorkloadRun } from '../../api/client'
5
+ import { WorkloadLogsViewer } from './WorkloadLogsViewer'
6
+ import { pickDefaultRun, workloadRunKey } from '../execution/BatchExecutionView'
7
+
8
+ interface ScheduledWorkloadLogsViewerProps {
9
+ kind: string
10
+ namespace: string
11
+ name: string
12
+ selectedRunKey?: string
13
+ onSelectRun?: (runKey: string) => void
14
+ }
15
+
16
+ const EMPTY_RUNS: WorkloadRun[] = []
17
+
18
+ export function ScheduledWorkloadLogsViewer({ kind, namespace, name, selectedRunKey, onSelectRun }: ScheduledWorkloadLogsViewerProps) {
19
+ const clusterScoped = kind === 'ClusterWorkflowTemplate' || kind === 'clusterworkflowtemplates'
20
+ const runsQuery = useWorkloadRuns(kind, namespace, name, true, { clusterScoped, refetchActive: true })
21
+ const runs = runsQuery.data?.runs ?? EMPTY_RUNS
22
+ const defaultRun = useMemo(() => pickDefaultRun(runs), [runs])
23
+ const [localRunKey, setLocalRunKey] = useState('')
24
+ const effectiveRunKey = selectedRunKey ?? localRunKey
25
+ const selectRun = onSelectRun ?? setLocalRunKey
26
+
27
+ useEffect(() => {
28
+ if (!runsQuery.data) return
29
+ if (runs.length === 0) {
30
+ if (effectiveRunKey) selectRun('')
31
+ return
32
+ }
33
+ if (!runs.some(run => workloadRunKey(run) === effectiveRunKey)) {
34
+ selectRun(workloadRunKey(defaultRun ?? runs[0]))
35
+ }
36
+ }, [runsQuery.data, runs, effectiveRunKey, defaultRun, selectRun])
37
+
38
+ const selectedRun = runs.find(run => workloadRunKey(run) === effectiveRunKey) ?? defaultRun
39
+
40
+ if (runsQuery.isLoading) {
41
+ return (
42
+ <div className="flex h-full items-center justify-center text-theme-text-tertiary">
43
+ <div className="flex items-center gap-2">
44
+ <Loader2 className="h-4 w-4 animate-spin" />
45
+ <span>Loading runs...</span>
46
+ </div>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ if (runsQuery.error) {
52
+ return (
53
+ <div className="flex h-full flex-col items-center justify-center gap-2 text-theme-text-tertiary">
54
+ <Terminal className="h-8 w-8" />
55
+ <span>{runsQuery.error instanceof Error ? runsQuery.error.message : 'Failed to load runs'}</span>
56
+ </div>
57
+ )
58
+ }
59
+
60
+ if (!selectedRun) {
61
+ return (
62
+ <div className="flex h-full flex-col items-center justify-center gap-2 text-theme-text-tertiary">
63
+ <Terminal className="h-8 w-8" />
64
+ <span>No retained runs found</span>
65
+ </div>
66
+ )
67
+ }
68
+
69
+ return (
70
+ <div className="flex h-full min-h-0 flex-col">
71
+ <div className="shrink-0 border-b border-theme-border bg-theme-surface px-3 py-2">
72
+ <div className="flex flex-wrap items-center gap-2">
73
+ <span className="text-xs font-medium text-theme-text-secondary">Run</span>
74
+ <select
75
+ value={workloadRunKey(selectedRun)}
76
+ onChange={(event) => selectRun(event.target.value)}
77
+ className="min-w-0 max-w-full rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-sm text-theme-text-primary"
78
+ >
79
+ {runs.map(run => (
80
+ <option key={workloadRunKey(run)} value={workloadRunKey(run)}>
81
+ {formatRunOption(run, clusterScoped)}
82
+ </option>
83
+ ))}
84
+ </select>
85
+ <span className={clsx('badge-sm', phaseBadgeClass(selectedRun.phase))}>
86
+ {selectedRun.phase}
87
+ </span>
88
+ <span className="text-xs text-theme-text-tertiary">
89
+ {formatRunTime(selectedRun)}
90
+ </span>
91
+ </div>
92
+ </div>
93
+ <div className="min-h-0 flex-1">
94
+ <WorkloadLogsViewer
95
+ key={`${selectedRun.kind}/${selectedRun.namespace}/${selectedRun.name}`}
96
+ kind={selectedRun.kind}
97
+ namespace={selectedRun.namespace}
98
+ name={selectedRun.name}
99
+ autoStream={selectedRun.active}
100
+ />
101
+ </div>
102
+ </div>
103
+ )
104
+ }
105
+
106
+ function phaseBadgeClass(phase: string): string {
107
+ switch (phase) {
108
+ case 'Succeeded':
109
+ return 'status-healthy'
110
+ case 'Running':
111
+ return 'status-neutral'
112
+ case 'Failed':
113
+ case 'Error':
114
+ return 'status-unhealthy'
115
+ case 'Pending':
116
+ return 'status-degraded'
117
+ default:
118
+ return 'status-unknown'
119
+ }
120
+ }
121
+
122
+ function formatRunTime(run: WorkloadRun): string {
123
+ const raw = run.startedAt || run.scheduledAt || run.finishedAt
124
+ if (!raw) return ''
125
+ return new Date(raw).toLocaleString()
126
+ }
127
+
128
+ function formatRunOption(run: WorkloadRun, showNamespace: boolean): string {
129
+ const bits = [showNamespace ? `${run.namespace}/${run.name}` : run.name, run.phase]
130
+ if (run.progress) bits.push(run.progress)
131
+ else if (run.desired) bits.push(`${run.succeeded ?? 0}/${run.desired}`)
132
+ const work = run.podTotal ? `${run.podSucceeded ?? 0}/${run.podTotal} pods` : ''
133
+ if (work) bits.push(work)
134
+ return bits.join(' · ')
135
+ }
@@ -7,6 +7,7 @@ import { useStartPortForward } from './PortForwardManager'
7
7
  import { useIsLocalDeployment } from '../../contexts/CapabilitiesContext'
8
8
  import { validatePort } from '@skyhook-io/k8s-ui/utils/validators'
9
9
  import { Tooltip } from '../ui/Tooltip'
10
+ import { Input } from '@skyhook-io/k8s-ui'
10
11
 
11
12
  interface PortForwardButtonProps {
12
13
  type: 'pod' | 'service'
@@ -123,9 +124,8 @@ function KubectlCommandDialog({
123
124
  <div className="flex flex-col gap-1">
124
125
  <div className="flex items-center gap-2 text-sm text-theme-text-secondary">
125
126
  <label htmlFor="local-port">Local port:</label>
126
- <input
127
+ <Input
127
128
  id="local-port"
128
- type="text"
129
129
  inputMode="numeric"
130
130
  value={portInput}
131
131
  onChange={(e) => setPortInput(e.target.value)}
@@ -644,7 +644,7 @@ export function PortForwardPanel() {
644
644
  onMouseEnter={onPanelHoverEnter}
645
645
  onMouseLeave={onPanelHoverLeave}
646
646
  className={clsx(
647
- 'fixed z-[51] w-80',
647
+ 'fixed z-[51] w-[26rem] max-w-[calc(100vw-2rem)]',
648
648
  'transition-opacity duration-150 ease-out',
649
649
  isPanelOpen
650
650
  ? 'opacity-100 pointer-events-auto'
@@ -726,14 +726,16 @@ export function PortForwardPanel() {
726
726
  </div>
727
727
  ) : (
728
728
  <div className="divide-y divide-theme-border">
729
- {activeSessions.map((session) => (
730
- <div
731
- key={session.id}
732
- className={clsx(
733
- 'p-3 space-y-1',
734
- session.status === 'error' ? 'bg-red-500/10' : 'hover:bg-theme-elevated'
735
- )}
736
- >
729
+ {activeSessions.map((session) => {
730
+ const resourceName = session.serviceName || session.podName
731
+ return (
732
+ <div
733
+ key={session.id}
734
+ className={clsx(
735
+ 'p-3 space-y-1',
736
+ session.status === 'error' ? 'bg-red-500/10' : 'hover:bg-theme-elevated'
737
+ )}
738
+ >
737
739
  {/* Row 1: status dot + name | stop button */}
738
740
  <div className="flex items-start justify-between gap-2">
739
741
  <div className="flex items-start gap-2 min-w-0 flex-1">
@@ -743,9 +745,11 @@ export function PortForwardPanel() {
743
745
  session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
744
746
  )}
745
747
  />
746
- <span className="text-sm text-theme-text-primary font-medium break-all line-clamp-2">
747
- {session.serviceName || session.podName}
748
- </span>
748
+ <Tooltip content={resourceName} delay={300} position="bottom" disabled={!isPanelOpen} wrapperClassName="min-w-0 flex-1">
749
+ <span className="block min-w-0 truncate text-sm font-medium text-theme-text-primary">
750
+ {resourceName}
751
+ </span>
752
+ </Tooltip>
749
753
  {session.status === 'error' && (
750
754
  <span className={clsx('badge-sm shrink-0', SEVERITY_BADGE.error)}>Failed</span>
751
755
  )}
@@ -916,8 +920,9 @@ export function PortForwardPanel() {
916
920
  </div>
917
921
  </div>
918
922
  )}
919
- </div>
920
- ))}
923
+ </div>
924
+ )
925
+ })}
921
926
  </div>
922
927
  )}
923
928
  </div>