@skyhook-io/radar-app 1.8.6 → 1.8.8

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 (109) hide show
  1. package/README.md +7 -1
  2. package/package.json +12 -10
  3. package/src/App.tsx +278 -230
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.delta.test.ts +89 -0
  7. package/src/api/client.deltaSync.test.ts +216 -0
  8. package/src/api/client.rightsizing.test.ts +32 -0
  9. package/src/api/client.ts +1462 -246
  10. package/src/api/diagnose.ts +288 -0
  11. package/src/api/timelineSource.test.ts +217 -0
  12. package/src/api/timelineSource.ts +582 -0
  13. package/src/components/ConnectionErrorView.tsx +174 -70
  14. package/src/components/ContextSwitcher.tsx +13 -5
  15. package/src/components/applications/ApplicationsView.tsx +775 -80
  16. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  17. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  18. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  19. package/src/components/cost/CostTrendChart.tsx +103 -72
  20. package/src/components/cost/CostView.test.ts +12 -0
  21. package/src/components/cost/CostView.tsx +494 -229
  22. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  23. package/src/components/cost/CostViewTabs.tsx +40 -0
  24. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  25. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  26. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  27. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  28. package/src/components/cost/cloud-console.test.ts +39 -0
  29. package/src/components/cost/cloud-console.ts +81 -0
  30. package/src/components/cost/errors.ts +8 -0
  31. package/src/components/cost/format.test.ts +27 -0
  32. package/src/components/cost/format.ts +46 -0
  33. package/src/components/cost/kinds.ts +5 -0
  34. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  35. package/src/components/diagnose/AISettings.tsx +116 -0
  36. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  37. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  38. package/src/components/diagnose/Home.tsx +163 -0
  39. package/src/components/diagnose/InvestigationView.tsx +604 -0
  40. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  41. package/src/components/diagnose/launch.ts +65 -0
  42. package/src/components/diagnose/parts.tsx +1689 -0
  43. package/src/components/dock/BottomDock.tsx +2 -3
  44. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  45. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  46. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  47. package/src/components/execution/batch-run-actions.test.ts +48 -0
  48. package/src/components/execution/batch-run-actions.ts +24 -0
  49. package/src/components/execution/batch-timeline.test.ts +57 -0
  50. package/src/components/execution/batch-timeline.ts +46 -0
  51. package/src/components/execution/execution-definition.test.ts +208 -0
  52. package/src/components/execution/execution-definition.ts +245 -0
  53. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  54. package/src/components/gitops/GitOpsView.tsx +81 -14
  55. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  56. package/src/components/helm/ChartBrowser.tsx +2 -3
  57. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  58. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  59. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  60. package/src/components/helm/HelmView.tsx +2 -3
  61. package/src/components/helm/InstallWizard.tsx +3 -5
  62. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  63. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  64. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  65. package/src/components/home/CostCard.tsx +21 -36
  66. package/src/components/home/HomeView.tsx +17 -15
  67. package/src/components/home/MCPSetupDialog.tsx +1 -1
  68. package/src/components/home/mcpToolCatalog.ts +2 -2
  69. package/src/components/issues/IssuesPane.tsx +9 -1
  70. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  71. package/src/components/portforward/PortForwardButton.tsx +2 -2
  72. package/src/components/portforward/PortForwardManager.tsx +19 -14
  73. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  74. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  75. package/src/components/resource/RightsizingStrip.tsx +320 -127
  76. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  77. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  78. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  79. package/src/components/resources/ResourcesView.tsx +13 -4
  80. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  81. package/src/components/resources/renderers/index.ts +1 -0
  82. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  83. package/src/components/rightsizing/copy.test.ts +56 -0
  84. package/src/components/rightsizing/model.test.ts +227 -0
  85. package/src/components/rightsizing/model.ts +158 -0
  86. package/src/components/rightsizing/presentation.test.ts +104 -0
  87. package/src/components/rightsizing/presentation.ts +94 -0
  88. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  89. package/src/components/settings/SettingsDialog.tsx +1290 -283
  90. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  91. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  92. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  93. package/src/components/timeline/TimelineList.tsx +81 -13
  94. package/src/components/timeline/TimelineView.tsx +842 -26
  95. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  96. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  97. package/src/components/traffic/TrafficView.tsx +8 -4
  98. package/src/components/ui/CommandPalette.tsx +2 -2
  99. package/src/components/workload/WorkloadView.tsx +912 -245
  100. package/src/context/ConnectionContext.tsx +109 -11
  101. package/src/context/DiagnoseCustomization.tsx +42 -0
  102. package/src/context/TimelineSource.tsx +50 -0
  103. package/src/hooks/useClusterLoadState.ts +73 -0
  104. package/src/hooks/useEventSource.ts +6 -0
  105. package/src/index.css +162 -1
  106. package/src/index.ts +12 -0
  107. package/src/main.tsx +1 -1
  108. package/src/types/clusterLoadState.ts +33 -0
  109. package/src/utils/navigation.ts +10 -0
@@ -1,6 +1,5 @@
1
1
  import { X, GitCompare } from 'lucide-react'
2
- import { PaneLoader } from '@skyhook-io/k8s-ui'
3
- import { clsx } from 'clsx'
2
+ import { PaneLoader, DiffLine, hasDiffBodyChange } from '@skyhook-io/k8s-ui'
4
3
 
5
4
  interface ManifestDiffViewerProps {
6
5
  diff: string
@@ -68,32 +67,3 @@ export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onCl
68
67
  </div>
69
68
  )
70
69
  }
71
-
72
- export function hasDiffBodyChange(diff: string): boolean {
73
- return diff.split('\n').some((line) => {
74
- if (!line || line.startsWith('---') || line.startsWith('+++') || line.startsWith('@@')) {
75
- return false
76
- }
77
- return line.startsWith('+') || line.startsWith('-')
78
- })
79
- }
80
-
81
- export function DiffLine({ line }: { line: string }) {
82
- const isAddition = line.startsWith('+') && !line.startsWith('+++')
83
- const isRemoval = line.startsWith('-') && !line.startsWith('---')
84
- const isHeader = line.startsWith('---') || line.startsWith('+++') || line.startsWith('@@')
85
-
86
- return (
87
- <div
88
- className={clsx(
89
- 'whitespace-pre',
90
- isAddition && 'bg-green-500/10 text-green-700 dark:text-green-400',
91
- isRemoval && 'bg-red-500/10 text-red-700 dark:text-red-400',
92
- isHeader && 'text-theme-text-tertiary font-bold',
93
- !isAddition && !isRemoval && !isHeader && 'text-theme-text-secondary'
94
- )}
95
- >
96
- {line || ' '}
97
- </div>
98
- )
99
- }
@@ -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,6 +1,8 @@
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'
5
+ import { classifyDiffLine } from '@skyhook-io/k8s-ui'
4
6
  import type { ValuesPreviewResponse } from '../../types'
5
7
 
6
8
  interface ValuesDiffPreviewProps {
@@ -8,6 +10,8 @@ interface ValuesDiffPreviewProps {
8
10
  onClose: () => void
9
11
  onApply: () => void
10
12
  isApplying: boolean
13
+ title?: string
14
+ applyLabel?: string
11
15
  }
12
16
 
13
17
  export function ValuesDiffPreview({
@@ -15,6 +19,8 @@ export function ValuesDiffPreview({
15
19
  onClose,
16
20
  onApply,
17
21
  isApplying,
22
+ title = 'Preview Changes',
23
+ applyLabel = 'Apply Changes',
18
24
  }: ValuesDiffPreviewProps) {
19
25
  const dialogRef = useRef<HTMLDivElement>(null)
20
26
 
@@ -40,7 +46,10 @@ export function ValuesDiffPreview({
40
46
  const hasChanges = previewData.manifestDiff.trim().length > 0 &&
41
47
  previewData.manifestDiff.split('\n').some(line => line.startsWith('+') || line.startsWith('-'))
42
48
 
43
- return (
49
+ // Portal to body so the modal escapes any CSS-transformed ancestor (the release
50
+ // drawer, the upgrade ConfirmDialog) and layers on top rather than being trapped
51
+ // in a lower stacking context.
52
+ return createPortal(
44
53
  <div className="fixed inset-0 z-50 flex items-center justify-center">
45
54
  {/* Backdrop */}
46
55
  <div
@@ -51,6 +60,8 @@ export function ValuesDiffPreview({
51
60
  {/* Dialog */}
52
61
  <div
53
62
  ref={dialogRef}
63
+ role="dialog"
64
+ aria-modal="true"
54
65
  tabIndex={-1}
55
66
  className="relative dialog max-w-4xl w-full mx-4 max-h-[85vh] flex flex-col outline-none"
56
67
  >
@@ -58,7 +69,7 @@ export function ValuesDiffPreview({
58
69
  <div className="flex items-center justify-between px-4 py-3 border-b border-theme-border shrink-0">
59
70
  <div className="flex items-center gap-2">
60
71
  <FileText className="w-5 h-5 text-blue-400" />
61
- <h3 className="text-lg font-semibold text-theme-text-primary">Preview Changes</h3>
72
+ <h3 className="text-lg font-semibold text-theme-text-primary">{title}</h3>
62
73
  </div>
63
74
  <button
64
75
  onClick={onClose}
@@ -120,12 +131,13 @@ export function ValuesDiffPreview({
120
131
  ) : (
121
132
  <Play className="w-4 h-4" />
122
133
  )}
123
- Apply Changes
134
+ {applyLabel}
124
135
  </button>
125
136
  </div>
126
137
  </div>
127
138
  </div>
128
- </div>
139
+ </div>,
140
+ document.body
129
141
  )
130
142
  }
131
143
 
@@ -158,9 +170,7 @@ interface DiffLineProps {
158
170
  }
159
171
 
160
172
  function DiffLine({ line, lineNumber }: DiffLineProps) {
161
- const isAddition = line.startsWith('+') && !line.startsWith('+++')
162
- const isDeletion = line.startsWith('-') && !line.startsWith('---')
163
- const isHeader = line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++')
173
+ const { isAddition, isRemoval: isDeletion, isHeader } = classifyDiffLine(line)
164
174
 
165
175
  return (
166
176
  <div
@@ -1,6 +1,12 @@
1
1
  import type { OpenCostSummary } from '../../api/client'
2
2
  import { useOpenCostSummary } from '../../api/client'
3
3
  import { DollarSign } from 'lucide-react'
4
+ import {
5
+ formatCostPerHour,
6
+ formatProjectedDailyRate,
7
+ formatProjectedMonthlyCost,
8
+ formatProjectedMonthlyRate,
9
+ } from '../cost/format'
4
10
 
5
11
  export function CostCard({ onNavigate }: { onNavigate?: () => void }) {
6
12
  const { data } = useOpenCostSummary()
@@ -15,7 +21,6 @@ export function CostCard({ onNavigate }: { onNavigate?: () => void }) {
15
21
 
16
22
  function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNavigate?: () => void }) {
17
23
  const hourlyCost = data.totalHourlyCost ?? 0
18
- const monthlyCost = hourlyCost * 730
19
24
  const namespaces = data.namespaces ?? []
20
25
  const topNamespaces = namespaces.slice(0, 5)
21
26
 
@@ -30,10 +35,10 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
30
35
  <div className="flex flex-col h-full w-full">
31
36
  <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
32
37
  <div className="flex items-center gap-2">
33
- <DollarSign className="w-4 h-4 text-indigo-500" />
34
- <span className="text-xs font-semibold uppercase tracking-wider text-indigo-500">Cost Insights</span>
38
+ <DollarSign className="w-4 h-4 text-accent-text" />
39
+ <span className="text-xs font-semibold uppercase tracking-wider text-accent-text">Cost Insights</span>
35
40
  {namespaces.length > 0 && (
36
- <span className="badge-sm bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40">
41
+ <span className="badge-sm border border-theme-border bg-accent-muted text-accent-text">
37
42
  {namespaces.length} ns
38
43
  </span>
39
44
  )}
@@ -45,13 +50,14 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
45
50
  <div className="flex items-baseline gap-3 mb-3">
46
51
  <div className="flex items-baseline gap-1">
47
52
  <span className="text-2xl font-bold text-theme-text-primary tabular-nums">
48
- {formatCost(hourlyCost)}
53
+ {formatProjectedMonthlyCost(hourlyCost)}
49
54
  </span>
50
- <span className="text-xs text-theme-text-tertiary">/hr</span>
55
+ <span className="text-xs text-theme-text-tertiary">/mo</span>
51
56
  </div>
52
- <div className="flex items-baseline gap-1 text-theme-text-secondary">
53
- <span className="text-sm font-medium tabular-nums">~{formatCost(monthlyCost)}</span>
54
- <span className="text-[10px] text-theme-text-tertiary">/mo</span>
57
+ <div className="flex items-baseline gap-1.5 text-theme-text-secondary">
58
+ <span className="text-xs font-medium tabular-nums">{formatProjectedDailyRate(hourlyCost)}</span>
59
+ <span className="text-[10px] text-theme-text-quaternary">·</span>
60
+ <span className="text-xs font-medium tabular-nums">{formatCostPerHour(hourlyCost)}</span>
55
61
  </div>
56
62
  </div>
57
63
 
@@ -63,30 +69,25 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
63
69
  <div key={ns.name} className="flex items-center gap-2">
64
70
  <span className="text-[11px] text-theme-text-secondary truncate w-24 shrink-0">{ns.name}</span>
65
71
  <div className="flex-1 h-2 rounded-full overflow-hidden bg-theme-hover">
66
- <div
67
- className="h-full rounded-full bg-indigo-500/60"
68
- style={{ width: `${Math.max(pct, 2)}%` }}
69
- />
72
+ <div className="h-full rounded-full bg-indigo-500/60" style={{ width: `${Math.max(pct, 2)}%` }} />
70
73
  </div>
71
- <span className="text-[10px] text-theme-text-tertiary tabular-nums w-14 text-right shrink-0">
72
- {formatCost(ns.hourlyCost)}/h
74
+ <span className="text-[10px] text-theme-text-tertiary tabular-nums w-20 text-right shrink-0">
75
+ {formatProjectedMonthlyRate(ns.hourlyCost)}
73
76
  </span>
74
77
  </div>
75
78
  )
76
79
  })}
77
80
  {namespaces.length > 5 && (
78
- <span className="text-[10px] text-theme-text-tertiary">
79
- +{namespaces.length - 5} more namespaces
80
- </span>
81
+ <span className="text-[10px] text-theme-text-tertiary">+{namespaces.length - 5} more namespaces</span>
81
82
  )}
82
83
  </div>
83
84
  </div>
84
85
 
85
86
  <div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-between">
86
87
  <span className="text-[10px] text-theme-text-tertiary">
87
- {data.currency ?? 'USD'} &middot; {data.window ?? '1h'} window
88
+ {data.currency ?? 'USD'} &middot; projected monthly from {data.window ?? '1h'} window
88
89
  </span>
89
- <span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-indigo-500">
90
+ <span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-accent-text">
90
91
  OpenCost
91
92
  </span>
92
93
  </div>
@@ -94,19 +95,3 @@ function CostCardContent({ data, onNavigate }: { data: OpenCostSummary; onNaviga
94
95
  </div>
95
96
  )
96
97
  }
97
-
98
- function formatCost(value: number): string {
99
- if (value >= 1000) {
100
- return `$${(value / 1000).toFixed(1)}k`
101
- }
102
- if (value >= 1) {
103
- return `$${value.toFixed(2)}`
104
- }
105
- if (value >= 0.01) {
106
- return `$${value.toFixed(3)}`
107
- }
108
- if (value > 0) {
109
- return `$${value.toFixed(4)}`
110
- }
111
- return '$0.00'
112
- }
@@ -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)}