@skyhook-io/radar-app 1.14.5 → 1.15.0

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 (117) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +142 -334
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.admission.test.ts +34 -0
  6. package/src/api/client.jobset.test.ts +18 -0
  7. package/src/api/client.resource-identity.test.ts +44 -0
  8. package/src/api/client.rightsizing.test.ts +7 -3
  9. package/src/api/client.ts +267 -78
  10. package/src/api/preferences.test.ts +54 -0
  11. package/src/api/preferences.ts +38 -0
  12. package/src/api/usage-data.fixtures.ts +33 -0
  13. package/src/api/usage-data.ts +165 -0
  14. package/src/components/CloudFunnelButton.test.tsx +155 -0
  15. package/src/components/CloudFunnelButton.tsx +113 -63
  16. package/src/components/ContextSwitcher.tsx +13 -132
  17. package/src/components/SelfManagedStart.tsx +96 -0
  18. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  19. package/src/components/applications/ApplicationsView.tsx +5 -3
  20. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  21. package/src/components/cloudConnectHandoff.test.ts +1 -1
  22. package/src/components/cloudConnectHandoff.ts +1 -1
  23. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  24. package/src/components/diagnose/AgentControls.tsx +26 -3
  25. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  26. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  27. package/src/components/diagnose/agentCatalog.ts +6 -0
  28. package/src/components/diagnose/launch.test.ts +23 -0
  29. package/src/components/diagnose/launch.ts +4 -0
  30. package/src/components/diagnose/parts.test.tsx +18 -0
  31. package/src/components/dialog-names.test.ts +69 -0
  32. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  33. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  34. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  35. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  36. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  37. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  38. package/src/components/execution/BatchExecutionView.tsx +324 -65
  39. package/src/components/execution/JobSetAdmission.test.tsx +165 -0
  40. package/src/components/execution/JobSetAdmission.tsx +26 -0
  41. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  42. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  43. package/src/components/execution/batch-run-actions.test.ts +1 -0
  44. package/src/components/execution/batch-timeline.test.ts +3 -0
  45. package/src/components/execution/execution-definition.test.ts +15 -1
  46. package/src/components/execution/execution-definition.ts +4 -0
  47. package/src/components/execution/member-collection.render.test.tsx +142 -0
  48. package/src/components/gitops/GitOpsView.tsx +33 -2
  49. package/src/components/gitops/destination-toast.test.ts +33 -0
  50. package/src/components/gitops/destination-toast.ts +46 -0
  51. package/src/components/gitops/useDestinationCluster.ts +30 -0
  52. package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
  53. package/src/components/home/HomeView.tsx +6 -2
  54. package/src/components/home/MCPSetupDialog.tsx +10 -0
  55. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  56. package/src/components/home/RadarVersionLine.tsx +28 -3
  57. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  58. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  59. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  60. package/src/components/nav/PrimaryNavRail.tsx +27 -8
  61. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  62. package/src/components/resource/PVCUsageBar.tsx +62 -16
  63. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  64. package/src/components/resources/ResourcesView.tsx +3 -3
  65. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  66. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  67. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  70. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  71. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  75. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  76. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  77. package/src/components/rightsizing/copy.test.ts +7 -0
  78. package/src/components/rightsizing/model.test.ts +2 -1
  79. package/src/components/rightsizing/notices.test.tsx +4 -3
  80. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  81. package/src/components/settings/PrivacySection.test.tsx +84 -0
  82. package/src/components/settings/PrivacySection.tsx +140 -0
  83. package/src/components/settings/SettingsDialog.tsx +168 -92
  84. package/src/components/settings/controls.tsx +55 -0
  85. package/src/components/settings/settings-state.test.ts +18 -0
  86. package/src/components/settings/settings-state.ts +19 -0
  87. package/src/components/timeline/TimelineView.tsx +9 -3
  88. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  89. package/src/components/ui/ErrorBoundary.tsx +5 -0
  90. package/src/components/ui/Omnibar.tsx +16 -0
  91. package/src/components/ui/UpdateNotification.tsx +13 -9
  92. package/src/components/ui/command-items.ts +14 -3
  93. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  94. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  95. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  96. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  97. package/src/components/useContextSwitchFlow.tsx +147 -0
  98. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  99. package/src/components/whats-new/WhatsNew.tsx +423 -0
  100. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  101. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  102. package/src/components/whats-new/releaseNotes.ts +121 -0
  103. package/src/components/workload/WorkloadView.test.ts +17 -1
  104. package/src/components/workload/WorkloadView.tsx +47 -19
  105. package/src/context/ContextSwitchContext.tsx +18 -2
  106. package/src/context/NavCustomization.tsx +4 -58
  107. package/src/context/ThemeContext.tsx +3 -11
  108. package/src/hooks/useClusterLoadState.ts +2 -2
  109. package/src/hooks/useFavorites.ts +3 -5
  110. package/src/k8s-ui-exports.test.ts +73 -0
  111. package/src/utils/auditBadges.ts +1 -1
  112. package/src/utils/navigation.test.ts +34 -1
  113. package/src/utils/navigation.ts +28 -5
  114. package/src/utils/topology-selection.test.ts +56 -0
  115. package/src/utils/topology-selection.ts +9 -9
  116. package/src/utils/version.ts +11 -0
  117. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -0,0 +1,46 @@
1
+ import type { DestinationCluster } from './useDestinationCluster'
2
+ import { parseContextForSwitcher } from '../../utils/context-name'
3
+
4
+ export interface DestinationToast {
5
+ message: string
6
+ detail: string
7
+ // Set when the resource can be opened by switching to a kubeconfig context.
8
+ actionLabel?: string
9
+ }
10
+
11
+ // What to tell someone who clicked a resource that lives on a remote GitOps
12
+ // object's destination cluster, and how they can open it.
13
+ export function destinationToast(resource: string, dest: DestinationCluster): DestinationToast {
14
+ if (dest.context) {
15
+ const cluster = parseContextForSwitcher(dest.context).clusterName
16
+ if (!dest.canSwitch) {
17
+ return {
18
+ message: `${resource} is on ${cluster}`,
19
+ detail: 'Switching clusters is turned off in this Radar.',
20
+ }
21
+ }
22
+ return {
23
+ message: `${resource} is on ${cluster}`,
24
+ detail: 'Opening it switches Radar to that cluster.',
25
+ actionLabel: `Open in ${cluster}`,
26
+ }
27
+ }
28
+ if (dest.server) {
29
+ return {
30
+ message: `${resource} is on another cluster`,
31
+ detail: `No kubeconfig context points at ${hostOf(dest.server)}. Add one to open it from Radar.`,
32
+ }
33
+ }
34
+ return {
35
+ message: `${resource} is on another cluster`,
36
+ detail: "Radar couldn't tell which cluster, so it can't open it from here.",
37
+ }
38
+ }
39
+
40
+ function hostOf(server: string): string {
41
+ try {
42
+ return new URL(server).host
43
+ } catch {
44
+ return server
45
+ }
46
+ }
@@ -0,0 +1,30 @@
1
+ import { useCapabilities, useContexts, useGitOpsDestination } from '../../api/client'
2
+ import type { ContextInfo } from '../../types'
3
+
4
+ export interface DestinationCluster {
5
+ // The destination's API server host, when Radar can tell.
6
+ server?: string
7
+ // A kubeconfig context for that cluster other than the current one.
8
+ context?: ContextInfo
9
+ // Whether this Radar may switch contexts at all.
10
+ canSwitch: boolean
11
+ }
12
+
13
+ // Resolves where a remote GitOps object deploys and which kubeconfig context
14
+ // reaches it. The server does the matching; this only asks for remote objects.
15
+ export function useDestinationCluster(
16
+ remote: boolean | undefined,
17
+ kind: string,
18
+ namespace: string,
19
+ name: string,
20
+ ): DestinationCluster {
21
+ const destination = useGitOpsDestination(kind, namespace, name, !!remote)
22
+ const contexts = useContexts()
23
+ const capabilities = useCapabilities()
24
+ const contextName = destination.data?.contexts[0]
25
+ return {
26
+ server: destination.data?.server || undefined,
27
+ context: contextName ? contexts.data?.find((c) => c.name === contextName) : undefined,
28
+ canSwitch: capabilities.data?.configManagement !== 'operator',
29
+ }
30
+ }
@@ -1,8 +1,9 @@
1
- import { useState } from 'react'
1
+ import { useState, useId } from 'react'
2
2
  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
- import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo } from '../../api/client'
5
+ import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo, useCapabilities } from '../../api/client'
6
+ import { OperatorManagedNotice } from '../settings/OperatorManagedNotice'
6
7
  import { Input } from '@skyhook-io/k8s-ui'
7
8
  import type { UpgradeInfo } from '../../types'
8
9
 
@@ -15,12 +16,14 @@ interface TrackChartSourceDialogProps {
15
16
  sourceError?: string
16
17
  }
17
18
 
18
- function getSourceIssueCopy(sourceIssue: UpgradeInfo['sourceIssue'], sourceError?: string) {
19
+ function getSourceIssueCopy(sourceIssue: UpgradeInfo['sourceIssue'], sourceError?: string, operatorManaged = false) {
19
20
  switch (sourceIssue) {
20
21
  case 'repo_index_error':
21
22
  return {
22
23
  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
+ body: operatorManaged
25
+ ? 'Ask the operator to fix the Helm repo index, or add the chart’s OCI registry prefix to helm.ociSources if it came from OCI.'
26
+ : '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
27
  }
25
28
  case 'ambiguous_repository':
26
29
  return {
@@ -30,7 +33,9 @@ function getSourceIssueCopy(sourceIssue: UpgradeInfo['sourceIssue'], sourceError
30
33
  case 'untracked':
31
34
  return {
32
35
  title: 'Source not tracked',
33
- body: 'Helm does not record the install source. Add the OCI prefix that contains this chart.',
36
+ body: operatorManaged
37
+ ? 'Helm does not record the install source. Ask the operator to add the OCI prefix that contains this chart to helm.ociSources.'
38
+ : 'Helm does not record the install source. Add the OCI prefix that contains this chart.',
34
39
  }
35
40
  default:
36
41
  return sourceError
@@ -45,11 +50,15 @@ function getSourceIssueCopy(sourceIssue: UpgradeInfo['sourceIssue'], sourceError
45
50
  // GitOps) Radar can only track upgrades once the user declares where they live.
46
51
  // Registering a registry/org prefix lets Radar probe "<prefix>/<chartName>".
47
52
  export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue, sourceError }: TrackChartSourceDialogProps) {
53
+ const titleId = useId()
48
54
  const [value, setValue] = useState('')
49
55
  const { data: sources } = useHelmOCISources()
50
56
  const { data: clusterInfo } = useClusterInfo()
51
57
  const addSource = useAddOCISource()
52
58
  const removeSource = useRemoveOCISource()
59
+ const { data: capabilities } = useCapabilities()
60
+ const operatorManaged = capabilities?.configManagement === 'operator'
61
+ const canEdit = capabilities != null && !operatorManaged
53
62
 
54
63
  // In-cluster Radar has no `helm registry login` store (the pod's HELM_CONFIG_HOME
55
64
  // points at an empty /tmp), so private registries can't authenticate — only
@@ -62,7 +71,7 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
62
71
  const normalizedChartName = chartName?.trim().replace(/^\/+|\/+$/g, '') ?? ''
63
72
  const probedRef = normalizedInput && normalizedChartName ? `${normalizedInput}/${normalizedChartName}` : ''
64
73
  const looksLikeFullChartRef = Boolean(normalizedInput && normalizedChartName && normalizedInput.endsWith(`/${normalizedChartName}`))
65
- const sourceIssueCopy = getSourceIssueCopy(sourceIssue, sourceError)
74
+ const sourceIssueCopy = getSourceIssueCopy(sourceIssue, sourceError, operatorManaged)
66
75
 
67
76
  const handleAdd = () => {
68
77
  if (!trimmed || invalid) return
@@ -70,20 +79,21 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
70
79
  }
71
80
 
72
81
  return (
73
- <DialogPortal open={open} onClose={onClose} className="max-w-lg w-full">
82
+ <DialogPortal ariaLabelledBy={titleId} open={open} onClose={onClose} className="max-w-lg w-full">
74
83
  <div className="flex items-start gap-3 p-4 border-b border-theme-border">
75
84
  <div className="flex items-center justify-center w-10 h-10 rounded-full shrink-0 bg-theme-hover">
76
85
  <Link2 className="w-5 h-5 text-theme-text-secondary" />
77
86
  </div>
78
87
  <div className="flex-1 min-w-0">
79
- <h3 className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
88
+ <h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
80
89
  <p className="text-sm text-theme-text-secondary mt-1">
81
- Helm doesn&apos;t record where a chart was installed from. Register your OCI
82
- registry prefix and Radar will check it for newer versions of your charts.
90
+ Helm doesn&apos;t record where a chart was installed from. Radar checks registered OCI
91
+ prefixes for newer versions of your charts.
83
92
  </p>
84
93
  </div>
85
94
  <button
86
95
  onClick={onClose}
96
+ aria-label="Close"
87
97
  className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
88
98
  >
89
99
  <X className="w-5 h-5" />
@@ -91,6 +101,7 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
91
101
  </div>
92
102
 
93
103
  <div className="p-4 space-y-4">
104
+ {operatorManaged && <OperatorManagedNotice helmValue="helm.ociSources" />}
94
105
  {sourceIssueCopy && (
95
106
  <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-sm">
96
107
  <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-theme-text-secondary" />
@@ -101,7 +112,7 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
101
112
  </div>
102
113
  )}
103
114
 
104
- <div>
115
+ {canEdit && <div>
105
116
  <label className="block text-sm font-medium text-theme-text-secondary mb-2">
106
117
  OCI registry prefix
107
118
  </label>
@@ -135,7 +146,7 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
135
146
  ? `Radar will probe "${probedRef || `<prefix>/${normalizedChartName}`}".`
136
147
  : 'Radar probes <prefix>/<chartName> for each untracked release.'}
137
148
  </p>
138
- </div>
149
+ </div>}
139
150
 
140
151
  {sources && sources.length > 0 && (
141
152
  <div>
@@ -149,14 +160,14 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
149
160
  className="flex items-center justify-between gap-2 px-3 py-2 bg-theme-elevated rounded-lg"
150
161
  >
151
162
  <span className="text-sm text-theme-text-primary font-mono truncate">{src}</span>
152
- <button
163
+ {canEdit && <button
153
164
  onClick={() => removeSource.mutate(src)}
154
165
  disabled={removeSource.isPending}
155
166
  className="p-1 text-theme-text-secondary hover:text-red-400 hover:bg-red-500/10 rounded disabled:opacity-50"
156
167
  aria-label={`Remove ${src}`}
157
168
  >
158
169
  <Trash2 className="w-4 h-4" />
159
- </button>
170
+ </button>}
160
171
  </li>
161
172
  ))}
162
173
  </ul>
@@ -52,9 +52,11 @@ interface HomeViewProps {
52
52
  onNavigateToUpgradeImpact?: () => void
53
53
  onNavigateToHelmRelease?: (namespace: string, release: string) => void
54
54
  onNavigateToManagerPath?: (path: string) => void
55
+ // Present only when release notes exist for this version.
56
+ onShowWhatsNew?: () => void
55
57
  }
56
58
 
57
- export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts, onNavigateToUpgradeImpact, onNavigateToHelmRelease, onNavigateToManagerPath }: HomeViewProps) {
59
+ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNavigateToView, onNavigateToResourceKind, onNavigateToResource, onNavigateToCerts, onNavigateToUpgradeImpact, onNavigateToHelmRelease, onNavigateToManagerPath, onShowWhatsNew }: HomeViewProps) {
58
60
  // The card itself decides whether the cluster has a capacity story
59
61
  // (available, softened-denied, or karpenterless-with-managers/groups) and
60
62
  // returns null otherwise — the outer gate only excludes states with nothing
@@ -138,9 +140,11 @@ export function HomeView({ namespaces, topology, fallbackClusterLoadState, onNav
138
140
  )}
139
141
  {/* Row 1: Cluster Health Card (combined health + resource counts) */}
140
142
  <ClusterHealthCard
141
- radarVersion={deploymentMode === 'in-cluster' && versionInfo ? (
143
+ radarVersion={versionInfo && deploymentMode !== 'cloud' ? (
142
144
  <RadarVersionLine
143
145
  version={versionInfo}
146
+ showUpgrade={deploymentMode === 'in-cluster'}
147
+ onShowWhatsNew={onShowWhatsNew}
144
148
  manager={installationManager}
145
149
  managerLoading={installationManagerLoading}
146
150
  onNavigateToHelmRelease={onNavigateToHelmRelease}
@@ -176,6 +176,15 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
176
176
  }
177
177
  }, null, 2)
178
178
 
179
+ const opencodeConfig = JSON.stringify({
180
+ mcp: {
181
+ radar: {
182
+ type: "remote",
183
+ url: mcpUrl,
184
+ }
185
+ }
186
+ }, null, 2)
187
+
179
188
  return (
180
189
  <div className="fixed inset-0 z-50 flex items-center justify-center">
181
190
  <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
@@ -283,6 +292,7 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
283
292
  { icon: Code2, name: 'VS Code Copilot', path: '.vscode/mcp.json', config: vsCodeConfig },
284
293
  { icon: Code2, name: 'Cline', path: 'Cline MCP settings (via UI)', config: clineConfig },
285
294
  { icon: Code2, name: 'JetBrains AI', path: 'Settings → Tools → AI Assistant → MCP', config: jetbrainsConfig },
295
+ { icon: Terminal, name: 'OpenCode', path: '~/.config/opencode/opencode.json', config: opencodeConfig },
286
296
  { icon: Terminal, name: 'OpenAI Codex', path: '~/.codex/config.toml', config: codexConfig },
287
297
  { icon: Terminal, name: 'Gemini CLI', path: '~/.gemini/settings.json', config: geminiConfig },
288
298
  ].map((agent) => (
@@ -142,4 +142,18 @@ describe('RadarVersionLine', () => {
142
142
  expect(html).toContain('Open the in-cluster upgrade instructions and apply the change through GitOps')
143
143
  expect(html).not.toContain('Open it to upgrade through GitOps')
144
144
  })
145
+
146
+ it('offers What\'s new only when the host can open it', () => {
147
+ const upToDate = { ...version, latestVersion: '1.2.3', updateAvailable: false }
148
+ expect(renderToString(<RadarVersionLine version={upToDate} />)).not.toContain('What&#x27;s new')
149
+ expect(renderToString(<RadarVersionLine version={upToDate} onShowWhatsNew={() => {}} />)).toContain('What&#x27;s new')
150
+ expect(renderToString(<RadarVersionLine version={version} onShowWhatsNew={() => {}} />)).toContain('What&#x27;s new')
151
+ })
152
+
153
+ it('hides upgrade guidance when the host handles upgrades elsewhere', () => {
154
+ const html = renderToString(<RadarVersionLine version={version} showUpgrade={false} onShowWhatsNew={() => {}} />)
155
+ expect(html).toContain('v1.2.3')
156
+ expect(html).not.toContain('available')
157
+ expect(html).toContain('What&#x27;s new')
158
+ })
145
159
  })
@@ -1,4 +1,4 @@
1
- import { ArrowUpCircle } from 'lucide-react'
1
+ import { ArrowUpCircle, Megaphone } from 'lucide-react'
2
2
  import { gitOpsRouteForResource } from '@skyhook-io/k8s-ui'
3
3
  import type { CloudConnectSelf, VersionInfo } from '../../api/client'
4
4
  import {
@@ -14,6 +14,10 @@ interface RadarVersionLineProps {
14
14
  managerLoading?: boolean
15
15
  onNavigateToHelmRelease?: (namespace: string, release: string) => void
16
16
  onNavigateToGitOps?: (path: string) => void
17
+ // Local installs upgrade through the update notification, and this line's
18
+ // upgrade link points at in-cluster instructions.
19
+ showUpgrade?: boolean
20
+ onShowWhatsNew?: () => void
17
21
  }
18
22
 
19
23
  function displayVersion(version: string): string {
@@ -26,13 +30,33 @@ export function RadarVersionLine({
26
30
  managerLoading = false,
27
31
  onNavigateToHelmRelease,
28
32
  onNavigateToGitOps,
33
+ showUpgrade: upgradeAllowed = true,
34
+ onShowWhatsNew,
29
35
  }: RadarVersionLineProps) {
30
36
  const latestVersion = version.latestVersion
31
37
  const updateStatus = getVersionUpdateStatus(version.currentVersion, latestVersion)
32
- const showUpgrade = version.updateAvailable && !!latestVersion && updateStatus.tier !== 'none'
38
+ const showUpgrade = upgradeAllowed && version.updateAvailable && !!latestVersion && updateStatus.tier !== 'none'
39
+ const whatsNew = onShowWhatsNew && (
40
+ <span className="inline-flex items-center gap-1">
41
+ <span aria-hidden>·</span>
42
+ <button
43
+ type="button"
44
+ onClick={onShowWhatsNew}
45
+ className="inline-flex items-center gap-1 text-accent-text hover:text-accent transition-colors"
46
+ >
47
+ <Megaphone className="h-3.5 w-3.5 shrink-0" aria-hidden />
48
+ What's new
49
+ </button>
50
+ </span>
51
+ )
33
52
 
34
53
  if (!showUpgrade) {
35
- return <span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
54
+ return (
55
+ <span className="inline-flex flex-wrap items-center gap-x-1">
56
+ <span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
57
+ {whatsNew}
58
+ </span>
59
+ )
36
60
  }
37
61
 
38
62
  const controller = manager?.controllerRef
@@ -111,6 +135,7 @@ export function RadarVersionLine({
111
135
  {action}
112
136
  </Tooltip>
113
137
  </span>
138
+ {whatsNew}
114
139
  </span>
115
140
  )
116
141
  }
@@ -1,9 +1,9 @@
1
1
  import { useEffect, useMemo, useState } from 'react'
2
2
  import { Loader2, Terminal } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
- import { useWorkloadRuns, type WorkloadRun } from '../../api/client'
4
+ import { useWorkloadRuns, useResource, type WorkloadRun } from '../../api/client'
5
5
  import { WorkloadLogsViewer } from './WorkloadLogsViewer'
6
- import { pickDefaultRun, workloadRunKey } from '../execution/BatchExecutionView'
6
+ import { pickDefaultRun, selectedRunMissing, workloadRunKey } from '../execution/BatchExecutionView'
7
7
 
8
8
  interface ScheduledWorkloadLogsViewerProps {
9
9
  kind: string
@@ -15,34 +15,61 @@ interface ScheduledWorkloadLogsViewerProps {
15
15
 
16
16
  const EMPTY_RUNS: WorkloadRun[] = []
17
17
 
18
- export function ScheduledWorkloadLogsViewer({ kind, namespace, name, selectedRunKey, onSelectRun }: ScheduledWorkloadLogsViewerProps) {
18
+ export function ScheduledWorkloadLogsViewer(props: ScheduledWorkloadLogsViewerProps) {
19
+ return <ScheduledWorkloadLogsContent key={JSON.stringify([props.kind, props.namespace, props.name])} {...props} />
20
+ }
21
+
22
+ function ScheduledWorkloadLogsContent({ kind, namespace, name, selectedRunKey, onSelectRun }: ScheduledWorkloadLogsViewerProps) {
19
23
  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])
24
+ const jobSet = kind.toLowerCase() === 'jobset' || kind.toLowerCase() === 'jobsets'
23
25
  const [localRunKey, setLocalRunKey] = useState('')
24
26
  const effectiveRunKey = selectedRunKey ?? localRunKey
25
27
  const selectRun = onSelectRun ?? setLocalRunKey
28
+ const [scope, setScope] = useState('selected')
29
+ const [role, setRole] = useState('')
30
+ const rootQuery = useResource<any>('jobsets', namespace, name, 'jobset.x-k8s.io', { enabled: jobSet })
31
+ const runsQuery = useWorkloadRuns(kind, namespace, name, true, { clusterScoped, refetchActive: true, ...(jobSet ? { selected: effectiveRunKey } : {}) })
32
+ const memberCollection = runsQuery.data?.collection === 'members'
33
+ const runs = runsQuery.data?.runs ?? EMPTY_RUNS
34
+ const selectedRunFromResponse = runsQuery.data?.selected
35
+ const resolvedRuns = useMemo(
36
+ () => selectedRunFromResponse && !runs.some(run => workloadRunKey(run) === workloadRunKey(selectedRunFromResponse))
37
+ ? [...runs, selectedRunFromResponse]
38
+ : runs,
39
+ [runs, selectedRunFromResponse],
40
+ )
41
+ const defaultRun = useMemo(() => memberCollection ? runs[0] : pickDefaultRun(runs), [memberCollection, runs])
42
+ const roles: string[] = [...new Set<string>([...(rootQuery.data?.spec?.replicatedJobs?.map((r: { name: string }) => r.name) ?? []), ...(role ? [role] : [])])]
43
+ const effectiveRole = role || roles[0] || ''
44
+ const aggregate = memberCollection && scope !== 'selected'
45
+ const scopeControls = memberCollection && <div className="flex flex-wrap items-center gap-2 border-b border-theme-border bg-theme-surface px-3 py-2 text-xs">
46
+ <label htmlFor="jobset-log-scope" className="text-theme-text-secondary">Log scope</label>
47
+ <select id="jobset-log-scope" value={scope} onChange={event => { if (event.target.value === 'role') setRole(effectiveRole); setScope(event.target.value) }} className="rounded border border-theme-border bg-theme-elevated px-2 py-1 text-theme-text-primary"><option value="selected">Selected Job</option><option value="role" disabled={roles.length === 0}>Role</option><option value="all">All current members</option></select>
48
+ {scope === 'role' && <select aria-label="Log role" value={effectiveRole} onChange={event => setRole(event.target.value)} className="rounded border border-theme-border bg-theme-elevated px-2 py-1 text-theme-text-primary">{roles.map(r => <option key={r} value={r}>{r}</option>)}</select>}
49
+ {aggregate && <span className="text-theme-text-secondary">Snapshot · Refresh for new logs · up to 40 container sources, 1,000 lines and 64 KiB per source · retained Pods only</span>}
50
+ </div>
51
+
52
+ const selectionMissing = memberCollection && selectedRunMissing(resolvedRuns, effectiveRunKey)
26
53
 
27
54
  useEffect(() => {
28
- if (!runsQuery.data) return
29
- if (runs.length === 0) {
55
+ if (!runsQuery.data || runsQuery.isPlaceholderData || selectionMissing) return
56
+ if (resolvedRuns.length === 0) {
30
57
  if (effectiveRunKey) selectRun('')
31
58
  return
32
59
  }
33
- if (!runs.some(run => workloadRunKey(run) === effectiveRunKey)) {
60
+ if (!resolvedRuns.some(run => workloadRunKey(run) === effectiveRunKey)) {
34
61
  selectRun(workloadRunKey(defaultRun ?? runs[0]))
35
62
  }
36
- }, [runsQuery.data, runs, effectiveRunKey, defaultRun, selectRun])
63
+ }, [runsQuery.data, runsQuery.isPlaceholderData, runs, resolvedRuns, effectiveRunKey, defaultRun, selectRun, selectionMissing])
37
64
 
38
- const selectedRun = runs.find(run => workloadRunKey(run) === effectiveRunKey) ?? defaultRun
65
+ const selectedRun = selectionMissing ? undefined : resolvedRuns.find(run => workloadRunKey(run) === effectiveRunKey) ?? defaultRun
39
66
 
40
67
  if (runsQuery.isLoading) {
41
68
  return (
42
69
  <div className="flex h-full items-center justify-center text-theme-text-tertiary">
43
70
  <div className="flex items-center gap-2">
44
71
  <Loader2 className="h-4 w-4 animate-spin" />
45
- <span>Loading runs...</span>
72
+ <span>{kind === 'JobSet' || kind === 'jobsets' ? 'Loading member Jobs...' : 'Loading runs...'}</span>
46
73
  </div>
47
74
  </div>
48
75
  )
@@ -52,7 +79,21 @@ export function ScheduledWorkloadLogsViewer({ kind, namespace, name, selectedRun
52
79
  return (
53
80
  <div className="flex h-full flex-col items-center justify-center gap-2 text-theme-text-tertiary">
54
81
  <Terminal className="h-8 w-8" />
55
- <span>{runsQuery.error instanceof Error ? runsQuery.error.message : 'Failed to load runs'}</span>
82
+ <span>{runsQuery.error instanceof Error ? runsQuery.error.message : kind === 'JobSet' || kind === 'jobsets' ? 'Failed to load member Jobs' : 'Failed to load runs'}</span>
83
+ </div>
84
+ )
85
+ }
86
+
87
+ if (aggregate) return <div className="flex h-full min-h-0 flex-col">{scopeControls}<div className="min-h-0 flex-1"><WorkloadLogsViewer key={JSON.stringify([rootQuery.data?.metadata?.uid, scope, effectiveRole])} kind="jobsets" namespace={namespace} name={name} snapshotOnly role={scope === 'role' ? effectiveRole : undefined} autoStream={false} /></div></div>
88
+
89
+ if (selectionMissing) {
90
+ return (
91
+ <div className="space-y-3 p-4">{scopeControls}
92
+ <p className="text-sm text-theme-text-secondary">Selected Job is currently unavailable. It may have been removed or be waiting for recreation. Your selection is preserved if it reappears; choose another shown Job to switch.</p>
93
+ {runs.length > 0 && <select aria-label="Select a shown member Job" value={effectiveRunKey} onChange={(event) => selectRun(event.target.value)} className="max-w-full rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-sm text-theme-text-primary">
94
+ <option value={effectiveRunKey} disabled>Choose a shown Job</option>
95
+ {runs.map((run) => <option key={workloadRunKey(run)} value={workloadRunKey(run)}>{formatRunOption(run, false, true)}</option>)}
96
+ </select>}
56
97
  </div>
57
98
  )
58
99
  }
@@ -61,38 +102,42 @@ export function ScheduledWorkloadLogsViewer({ kind, namespace, name, selectedRun
61
102
  return (
62
103
  <div className="flex h-full flex-col items-center justify-center gap-2 text-theme-text-tertiary">
63
104
  <Terminal className="h-8 w-8" />
64
- <span>No retained runs found</span>
105
+ <span>{memberCollection ? 'No child Jobs found' : 'No retained runs found'}</span>
65
106
  </div>
66
107
  )
67
108
  }
68
109
 
69
110
  return (
70
111
  <div className="flex h-full min-h-0 flex-col">
112
+ {scopeControls}
71
113
  <div className="shrink-0 border-b border-theme-border bg-theme-surface px-3 py-2">
72
114
  <div className="flex flex-wrap items-center gap-2">
73
- <span className="text-xs font-medium text-theme-text-secondary">Run</span>
115
+ <span className="text-xs font-medium text-theme-text-secondary">{memberCollection ? 'Job' : 'Run'}</span>
74
116
  <select
75
117
  value={workloadRunKey(selectedRun)}
76
118
  onChange={(event) => selectRun(event.target.value)}
77
119
  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
120
  >
79
- {runs.map(run => (
121
+ {resolvedRuns.map(run => (
80
122
  <option key={workloadRunKey(run)} value={workloadRunKey(run)}>
81
- {formatRunOption(run, clusterScoped)}
123
+ {formatRunOption(run, clusterScoped, memberCollection)}
82
124
  </option>
83
125
  ))}
84
126
  </select>
85
127
  <span className={clsx('badge-sm', phaseBadgeClass(selectedRun.phase))}>
86
- {selectedRun.phase}
128
+ {selectedRun.phase}{selectedRun.deleting ? ' · deleting' : ''}
87
129
  </span>
88
130
  <span className="text-xs text-theme-text-tertiary">
89
131
  {formatRunTime(selectedRun)}
90
132
  </span>
133
+ {memberCollection && runsQuery.data?.truncated && (
134
+ <span className="text-xs text-theme-text-tertiary">Showing {runs.length} of {runsQuery.data.total} Jobs</span>
135
+ )}
91
136
  </div>
92
137
  </div>
93
138
  <div className="min-h-0 flex-1">
94
139
  <WorkloadLogsViewer
95
- key={`${selectedRun.kind}/${selectedRun.namespace}/${selectedRun.name}`}
140
+ key={workloadRunLogsKey(selectedRun)}
96
141
  kind={selectedRun.kind}
97
142
  namespace={selectedRun.namespace}
98
143
  name={selectedRun.name}
@@ -125,11 +170,19 @@ function formatRunTime(run: WorkloadRun): string {
125
170
  return new Date(raw).toLocaleString()
126
171
  }
127
172
 
128
- function formatRunOption(run: WorkloadRun, showNamespace: boolean): string {
129
- const bits = [showNamespace ? `${run.namespace}/${run.name}` : run.name, run.phase]
173
+ function formatRunOption(run: WorkloadRun, showNamespace: boolean, memberCollection: boolean): string {
174
+ const bits = [showNamespace ? `${run.namespace}/${run.name}` : run.name, `${run.phase}${run.deleting ? ' · deleting' : ''}`]
175
+ if (memberCollection && run.jobset?.replicatedJob) {
176
+ bits.push(`${run.jobset?.replicatedJob}${run.jobset?.jobIndex ? ` #${run.jobset?.jobIndex}` : ''}`)
177
+ }
130
178
  if (run.progress) bits.push(run.progress)
131
179
  else if (run.desired) bits.push(`${run.succeeded ?? 0}/${run.desired}`)
132
- const work = run.podTotal ? `${run.podSucceeded ?? 0}/${run.podTotal} pods` : ''
180
+ const work = run.podTotal ? `${run.podSucceeded ?? 0}/${run.podTotal} completed Pods` : ''
133
181
  if (work) bits.push(work)
134
182
  return bits.join(' · ')
135
183
  }
184
+
185
+ // A JobSet retry can replace a child Job at the same resource address.
186
+ export function workloadRunLogsKey(run: WorkloadRun): string {
187
+ return JSON.stringify([run.group, workloadRunKey(run), run.jobset?.restartAttempt, run.jobset?.jobRestartAttempt])
188
+ }
@@ -10,24 +10,27 @@ interface WorkloadLogsViewerProps {
10
10
  namespace: string
11
11
  name: string
12
12
  /** Start streaming on mount. Default true — workload logs are aggregated from live pods. */
13
+ snapshotOnly?: boolean
14
+ role?: string
13
15
  autoStream?: boolean
14
16
  }
15
17
 
16
- export function WorkloadLogsViewer({ kind, namespace, name, autoStream = true }: WorkloadLogsViewerProps) {
18
+ export function WorkloadLogsViewer({ kind, namespace, name, autoStream = true, snapshotOnly = false, role }: WorkloadLogsViewerProps) {
17
19
  const desktopDownload = useDesktopDownload()
18
20
  const { theme } = useTheme()
19
21
 
20
22
  const fetchAll = useCallback(async (params: WorkloadLogsFetchParams): Promise<WorkloadLogsResult> => {
21
23
  const query = new URLSearchParams()
22
24
  if (params.container) query.set('container', params.container)
25
+ if (role) query.set('role', role)
23
26
  if (params.tailLines) query.set('tailLines', String(params.tailLines))
24
27
  if (params.sinceSeconds) query.set('sinceSeconds', String(params.sinceSeconds))
25
28
  const qs = query.toString()
26
29
  const data = await fetchJSON<WorkloadLogsResult>(
27
- `/workloads/${kind}/${namespace}/${name}/logs${qs ? `?${qs}` : ''}`
30
+ `${snapshotOnly ? `/jobsets/${namespace}/${name}` : `/workloads/${kind}/${namespace}/${name}`}/logs${qs ? `?${qs}` : ''}`, { signal: params.signal }
28
31
  )
29
32
  return data
30
- }, [kind, namespace, name])
33
+ }, [kind, namespace, name, snapshotOnly, role])
31
34
 
32
35
  const makeStream = useCallback((params: WorkloadLogsFetchParams) => {
33
36
  return createWorkloadLogStream(kind, namespace, name, params)
@@ -37,7 +40,7 @@ export function WorkloadLogsViewer({ kind, namespace, name, autoStream = true }:
37
40
  <SharedWorkloadLogsViewer
38
41
  name={name}
39
42
  fetchAll={fetchAll}
40
- createStream={makeStream}
43
+ createStream={snapshotOnly ? undefined : makeStream}
41
44
  overrideDownload={desktopDownload}
42
45
  forceDark={theme === 'dark' ? true : undefined}
43
46
  autoStream={autoStream}
@@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest";
2
2
  import { renderToString } from "react-dom/server";
3
3
  import { PrimaryNavRail } from "./PrimaryNavRail";
4
4
 
5
- function renderRail() {
5
+ function renderRail(whatsNew?: { unread: boolean; onOpen: () => void }) {
6
6
  return renderToString(
7
7
  <PrimaryNavRail
8
8
  activeView="home"
9
9
  onNavigate={() => {}}
10
10
  pinned
11
11
  onTogglePinned={() => {}}
12
+ whatsNew={whatsNew}
12
13
  />,
13
14
  );
14
15
  }
@@ -17,4 +18,12 @@ describe("PrimaryNavRail", () => {
17
18
  it("always surfaces Capacity — the view reads cluster capacity across every node manager, not just Karpenter", () => {
18
19
  expect(renderRail()).toContain("Capacity");
19
20
  });
21
+
22
+ it("shows What's new only when the host has notes, and marks it while unread", () => {
23
+ expect(renderRail()).not.toContain("What&#x27;s new");
24
+ const read = renderRail({ unread: false, onOpen: () => {} });
25
+ expect(read).toContain("What&#x27;s new");
26
+ expect(read).not.toContain("(unread)");
27
+ expect(renderRail({ unread: true, onOpen: () => {} })).toContain("(unread)");
28
+ });
20
29
  });