@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.
- package/README.md +2 -9
- package/package.json +6 -6
- package/src/App.tsx +142 -334
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +267 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +113 -63
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/SelfManagedStart.tsx +96 -0
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +18 -0
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +165 -0
- package/src/components/execution/JobSetAdmission.tsx +26 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
- package/src/components/home/HomeView.tsx +6 -2
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +27 -8
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/model.test.ts +2 -1
- package/src/components/rightsizing/notices.test.tsx +4 -3
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/SettingsDialog.tsx +168 -92
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +19 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +16 -0
- package/src/components/ui/UpdateNotification.tsx +13 -9
- package/src/components/ui/command-items.ts +14 -3
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +47 -19
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +34 -1
- package/src/utils/navigation.ts +28 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/utils/version.ts +11 -0
- 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:
|
|
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:
|
|
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't record where a chart was installed from.
|
|
82
|
-
|
|
90
|
+
Helm doesn'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
|
|
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's new')
|
|
149
|
+
expect(renderToString(<RadarVersionLine version={upToDate} onShowWhatsNew={() => {}} />)).toContain('What's new')
|
|
150
|
+
expect(renderToString(<RadarVersionLine version={version} onShowWhatsNew={() => {}} />)).toContain('What'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'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
|
|
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(
|
|
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
|
|
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 (
|
|
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 (!
|
|
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 =
|
|
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
|
|
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
|
-
{
|
|
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={
|
|
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}
|
|
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's new");
|
|
24
|
+
const read = renderRail({ unread: false, onOpen: () => {} });
|
|
25
|
+
expect(read).toContain("What's new");
|
|
26
|
+
expect(read).not.toContain("(unread)");
|
|
27
|
+
expect(renderRail({ unread: true, onOpen: () => {} })).toContain("(unread)");
|
|
28
|
+
});
|
|
20
29
|
});
|