@skyhook-io/radar-app 1.8.1 → 1.8.3
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/package.json +1 -1
- package/src/App.tsx +167 -56
- package/src/RadarApp.tsx +18 -1
- package/src/api/client.ts +173 -6
- package/src/components/NamespaceSwitcher.tsx +52 -30
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/gitops/GitOpsView.tsx +1 -10
- package/src/components/helm/HelmReleaseDrawer.tsx +802 -44
- package/src/components/helm/HelmView.tsx +85 -16
- package/src/components/helm/ManifestDiffViewer.tsx +15 -4
- package/src/components/helm/OwnedResources.tsx +14 -50
- package/src/components/helm/RevisionHistory.tsx +50 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
- package/src/components/helm/ValuesViewer.tsx +41 -11
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +10 -10
- package/src/components/nav/PrimaryNavRail.tsx +1 -1
- package/src/components/portforward/PortForwardButton.tsx +69 -25
- package/src/components/portforward/PortForwardManager.tsx +18 -4
- package/src/components/resources/ResourcesView.tsx +42 -1
- package/src/components/resources/renderers/PodRenderer.tsx +7 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
- package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +5 -10
- package/src/components/ui/command-items.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +52 -7
- package/src/context/ConnectionContext.tsx +29 -2
- package/src/contexts/CapabilitiesContext.tsx +8 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/main.tsx +5 -3
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +5 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react'
|
|
1
|
+
import { useState, useCallback, useEffect } from 'react'
|
|
2
2
|
import { Copy, Check, Settings, Pencil, X, Eye, Play, Loader2 } from 'lucide-react'
|
|
3
3
|
import { PaneLoader } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import { clsx } from 'clsx'
|
|
@@ -21,6 +21,8 @@ interface ValuesViewerProps {
|
|
|
21
21
|
// Required for editing
|
|
22
22
|
namespace?: string
|
|
23
23
|
name?: string
|
|
24
|
+
revision?: number
|
|
25
|
+
currentRevision?: number
|
|
24
26
|
onApplySuccess?: () => void
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -33,6 +35,8 @@ export function ValuesViewer({
|
|
|
33
35
|
copied,
|
|
34
36
|
namespace,
|
|
35
37
|
name,
|
|
38
|
+
revision,
|
|
39
|
+
currentRevision,
|
|
36
40
|
onApplySuccess,
|
|
37
41
|
}: ValuesViewerProps) {
|
|
38
42
|
const [isEditing, setIsEditing] = useState(false)
|
|
@@ -44,8 +48,9 @@ export function ValuesViewer({
|
|
|
44
48
|
const previewMutation = useHelmPreviewValues()
|
|
45
49
|
const applyMutation = useHelmApplyValues()
|
|
46
50
|
const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
|
|
51
|
+
const isHistoricalRevision = typeof revision === 'number' && typeof currentRevision === 'number' && revision !== currentRevision
|
|
47
52
|
|
|
48
|
-
const canEdit = Boolean(namespace && name) && canHelmWrite
|
|
53
|
+
const canEdit = Boolean(namespace && name) && canHelmWrite && !isHistoricalRevision
|
|
49
54
|
|
|
50
55
|
const displayValues = showAllValues && values?.computed ? values.computed : values?.userSupplied
|
|
51
56
|
const isEmpty = !displayValues || Object.keys(displayValues).length === 0
|
|
@@ -72,6 +77,12 @@ export function ValuesViewer({
|
|
|
72
77
|
setShowPreview(false)
|
|
73
78
|
}, [])
|
|
74
79
|
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (isHistoricalRevision && isEditing) {
|
|
82
|
+
handleCancelEdit()
|
|
83
|
+
}
|
|
84
|
+
}, [isHistoricalRevision, isEditing, handleCancelEdit])
|
|
85
|
+
|
|
75
86
|
// Parse YAML and validate
|
|
76
87
|
const parseYaml = useCallback((yamlStr: string): Record<string, unknown> | null => {
|
|
77
88
|
try {
|
|
@@ -86,7 +97,7 @@ export function ValuesViewer({
|
|
|
86
97
|
|
|
87
98
|
// Preview changes
|
|
88
99
|
const handlePreview = useCallback(async () => {
|
|
89
|
-
if (!namespace || !name) return
|
|
100
|
+
if (!namespace || !name || isHistoricalRevision) return
|
|
90
101
|
const parsed = parseYaml(editedYaml)
|
|
91
102
|
if (!parsed) return
|
|
92
103
|
|
|
@@ -101,11 +112,11 @@ export function ValuesViewer({
|
|
|
101
112
|
} catch {
|
|
102
113
|
// Error is handled by mutation
|
|
103
114
|
}
|
|
104
|
-
}, [namespace, name, editedYaml, parseYaml, previewMutation])
|
|
115
|
+
}, [namespace, name, isHistoricalRevision, editedYaml, parseYaml, previewMutation])
|
|
105
116
|
|
|
106
117
|
// Apply changes
|
|
107
118
|
const handleApply = useCallback(async () => {
|
|
108
|
-
if (!namespace || !name) return
|
|
119
|
+
if (!namespace || !name || isHistoricalRevision) return
|
|
109
120
|
const parsed = parseYaml(editedYaml)
|
|
110
121
|
if (!parsed) return
|
|
111
122
|
|
|
@@ -120,11 +131,11 @@ export function ValuesViewer({
|
|
|
120
131
|
} catch {
|
|
121
132
|
// Error is handled by mutation
|
|
122
133
|
}
|
|
123
|
-
}, [namespace, name, editedYaml, parseYaml, applyMutation, handleCancelEdit, onApplySuccess])
|
|
134
|
+
}, [namespace, name, isHistoricalRevision, editedYaml, parseYaml, applyMutation, handleCancelEdit, onApplySuccess])
|
|
124
135
|
|
|
125
136
|
// Apply from preview modal
|
|
126
137
|
const handleApplyFromPreview = useCallback(async () => {
|
|
127
|
-
if (!previewData || !namespace || !name) return
|
|
138
|
+
if (!previewData || !namespace || !name || isHistoricalRevision) return
|
|
128
139
|
try {
|
|
129
140
|
await applyMutation.mutateAsync({
|
|
130
141
|
namespace,
|
|
@@ -137,7 +148,7 @@ export function ValuesViewer({
|
|
|
137
148
|
} catch {
|
|
138
149
|
// Error is handled by mutation
|
|
139
150
|
}
|
|
140
|
-
}, [previewData, namespace, name, applyMutation, handleCancelEdit, onApplySuccess])
|
|
151
|
+
}, [previewData, namespace, name, isHistoricalRevision, applyMutation, handleCancelEdit, onApplySuccess])
|
|
141
152
|
|
|
142
153
|
if (isLoading) {
|
|
143
154
|
return <PaneLoader label="Loading values…" className="h-32" />
|
|
@@ -147,7 +158,12 @@ export function ValuesViewer({
|
|
|
147
158
|
return (
|
|
148
159
|
<div className="p-4">
|
|
149
160
|
<div className="flex items-center justify-between mb-3">
|
|
150
|
-
<
|
|
161
|
+
<div className="flex items-center gap-2">
|
|
162
|
+
<span className="text-sm font-medium text-theme-text-secondary">Values</span>
|
|
163
|
+
{isHistoricalRevision && (
|
|
164
|
+
<span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">revision {revision}</span>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
151
167
|
<div className="flex items-center gap-2">
|
|
152
168
|
<ToggleButton showAll={showAllValues} onToggle={onToggleAllValues} disabled={isEditing} />
|
|
153
169
|
{canEdit && (
|
|
@@ -161,6 +177,11 @@ export function ValuesViewer({
|
|
|
161
177
|
)}
|
|
162
178
|
</div>
|
|
163
179
|
</div>
|
|
180
|
+
{isHistoricalRevision && (
|
|
181
|
+
<div className="mb-3 rounded border border-theme-border bg-theme-elevated/40 px-3 py-2 text-xs text-theme-text-secondary">
|
|
182
|
+
Viewing historical values. Switch back to the latest revision before editing or applying changes.
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
164
185
|
<div className="flex flex-col items-center justify-center h-32 text-theme-text-tertiary gap-2">
|
|
165
186
|
<Settings className="w-8 h-8 text-theme-text-disabled" />
|
|
166
187
|
<span>{showAllValues ? 'No computed values' : 'No user-supplied values'}</span>
|
|
@@ -179,6 +200,9 @@ export function ValuesViewer({
|
|
|
179
200
|
<span className="text-sm font-medium text-theme-text-secondary">
|
|
180
201
|
{isEditing ? 'Editing Values' : showAllValues ? 'All Values (Computed)' : 'User-Supplied Values'}
|
|
181
202
|
</span>
|
|
203
|
+
{isHistoricalRevision && !isEditing && (
|
|
204
|
+
<span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">revision {revision}</span>
|
|
205
|
+
)}
|
|
182
206
|
{isEditing && (
|
|
183
207
|
<span className="badge-sm bg-amber-500/20 text-amber-400 border-amber-500/30">
|
|
184
208
|
unsaved
|
|
@@ -218,7 +242,7 @@ export function ValuesViewer({
|
|
|
218
242
|
</button>
|
|
219
243
|
<button
|
|
220
244
|
onClick={handlePreview}
|
|
221
|
-
disabled={!!yamlError || previewMutation.isPending}
|
|
245
|
+
disabled={!!yamlError || previewMutation.isPending || isHistoricalRevision}
|
|
222
246
|
className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded border border-theme-border disabled:opacity-50 disabled:cursor-not-allowed"
|
|
223
247
|
>
|
|
224
248
|
{previewMutation.isPending ? (
|
|
@@ -231,7 +255,7 @@ export function ValuesViewer({
|
|
|
231
255
|
<Tooltip content={!canHelmWrite ? helmActReason : ''}>
|
|
232
256
|
<button
|
|
233
257
|
onClick={handleApply}
|
|
234
|
-
disabled={!!yamlError || applyMutation.isPending || !canHelmWrite}
|
|
258
|
+
disabled={!!yamlError || applyMutation.isPending || !canHelmWrite || isHistoricalRevision}
|
|
235
259
|
className="flex items-center gap-1 px-2.5 py-1 text-xs btn-brand rounded disabled:cursor-not-allowed disabled:pointer-events-none"
|
|
236
260
|
>
|
|
237
261
|
{applyMutation.isPending ? (
|
|
@@ -247,6 +271,12 @@ export function ValuesViewer({
|
|
|
247
271
|
</div>
|
|
248
272
|
</div>
|
|
249
273
|
|
|
274
|
+
{isHistoricalRevision && (
|
|
275
|
+
<div className="mb-3 rounded border border-theme-border bg-theme-elevated/40 px-3 py-2 text-xs text-theme-text-secondary">
|
|
276
|
+
Viewing historical values. Switch back to the latest revision before editing or applying changes.
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
|
|
250
280
|
{/* Error message */}
|
|
251
281
|
{yamlError && (
|
|
252
282
|
<div className="mb-3 px-3 py-2 text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded">
|
|
@@ -100,7 +100,7 @@ export function TrafficSummary({ data, onNavigate }: TrafficSummaryProps) {
|
|
|
100
100
|
<div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
|
|
101
101
|
<div className="flex items-center gap-2">
|
|
102
102
|
<Activity className="w-4 h-4 text-theme-text-tertiary" />
|
|
103
|
-
<span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">Traffic</span>
|
|
103
|
+
<span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">Live Traffic</span>
|
|
104
104
|
</div>
|
|
105
105
|
{hasFlows && (
|
|
106
106
|
<span className="text-[11px] text-theme-text-tertiary">
|
|
@@ -145,7 +145,7 @@ export function TrafficSummary({ data, onNavigate }: TrafficSummaryProps) {
|
|
|
145
145
|
</div>
|
|
146
146
|
|
|
147
147
|
<div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-end gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-theme-text-secondary group-hover:text-theme-text-primary transition-colors">
|
|
148
|
-
Open Traffic
|
|
148
|
+
Open Live Traffic
|
|
149
149
|
<ArrowRight className="w-3.5 h-3.5 transition-transform group-hover:translate-x-0.5" />
|
|
150
150
|
</div>
|
|
151
151
|
</div>
|
|
@@ -109,9 +109,9 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
name: 'diagnose',
|
|
112
|
-
desc: 'One-call root-cause bundle. Workloads get spec + resourceContext + current AND previous logs across pods + warning events + startup blockers; GitOps reconcilers get status summary + parsed related issues.',
|
|
112
|
+
desc: 'One-call root-cause bundle. Workloads get spec + resourceContext + current AND previous logs across pods + warning events + startup blockers; GitOps reconcilers, including Flux HelmRelease, get status summary + parsed related issues.',
|
|
113
113
|
params: [
|
|
114
|
-
{ arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, application, kustomization, or
|
|
114
|
+
{ arg: 'kind', required: true, desc: 'pod, deployment, statefulset, daemonset, application, kustomization, or Flux HelmRelease' },
|
|
115
115
|
{ arg: 'namespace', required: true, desc: 'resource namespace' },
|
|
116
116
|
{ arg: 'name', required: true, desc: 'resource name' },
|
|
117
117
|
{ arg: 'container', desc: 'specific container (defaults to all)' },
|
|
@@ -126,7 +126,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
name: 'get_changes',
|
|
129
|
-
desc: 'Recent
|
|
129
|
+
desc: 'Recent meaningful changes from the Kubernetes timeline plus native Helm deployment history (source=helm). Includes failed upgrades, rollbacks, and current Helm revisions; sourcesErrored marks partial source failures.',
|
|
130
130
|
params: [
|
|
131
131
|
{ arg: 'namespace', desc: 'filter to a specific namespace' },
|
|
132
132
|
{ arg: 'kind', desc: 'filter to a resource kind (e.g. Deployment)' },
|
|
@@ -147,18 +147,18 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
149
|
name: 'list_helm_releases',
|
|
150
|
-
desc: 'All Helm releases
|
|
150
|
+
desc: 'All Helm releases with status, resource health, storage namespace, Flux ownership, current lastOperation, and capped operation trails for failed upgrades, rollbacks, or stuck pending operations.',
|
|
151
151
|
params: [{ arg: 'namespace', desc: 'filter to a specific namespace' }],
|
|
152
152
|
},
|
|
153
153
|
{
|
|
154
154
|
name: 'get_helm_release',
|
|
155
|
-
desc: 'Detailed Helm release info with owned resources
|
|
155
|
+
desc: 'Detailed Helm release info with owned resources, health, Flux ownership, current lastOperation, hooks, and failed/running hook diagnostics with live Job/Pod/Event/redacted-log evidence when available.',
|
|
156
156
|
params: [
|
|
157
|
-
{ arg: 'namespace', required: true, desc: '
|
|
157
|
+
{ arg: 'namespace', required: true, desc: 'Helm storage namespace; use storageNamespace from list_helm_releases when present' },
|
|
158
158
|
{ arg: 'name', required: true, desc: 'release name' },
|
|
159
|
-
{ arg: 'include', desc: 'values, history, diff' },
|
|
160
|
-
{ arg: 'diff_revision_1', desc: 'first revision for diff' },
|
|
161
|
-
{ arg: 'diff_revision_2', desc: 'second revision for diff (defaults to current)' },
|
|
159
|
+
{ arg: 'include', desc: 'values, history, operations, diff, values_diff, notes_diff, resource_diff' },
|
|
160
|
+
{ arg: 'diff_revision_1', desc: 'first revision for any diff include' },
|
|
161
|
+
{ arg: 'diff_revision_2', desc: 'second revision for any diff include (defaults to current)' },
|
|
162
162
|
],
|
|
163
163
|
},
|
|
164
164
|
{
|
|
@@ -172,7 +172,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
172
172
|
},
|
|
173
173
|
{
|
|
174
174
|
name: 'issues',
|
|
175
|
-
desc: 'Ranked list of what is broken right now — failing workloads, dangling references, scheduling blockers, and false CRD conditions.
|
|
175
|
+
desc: 'Ranked list of what is broken right now — failing workloads, active native Helm release failures or stuck pending operations, dangling references, scheduling blockers, and false CRD conditions. Native Helm rows use group=helm.sh.',
|
|
176
176
|
params: [
|
|
177
177
|
{ arg: 'namespace', desc: 'filter to one namespace' },
|
|
178
178
|
{ arg: 'severity', desc: 'comma-separated: critical, warning' },
|
|
@@ -45,7 +45,7 @@ const NAV_ITEMS: NavItemDef[] = [
|
|
|
45
45
|
{ view: 'topology', icon: Network, label: 'Topology' },
|
|
46
46
|
{ view: 'applications', icon: Boxes, label: 'Applications' },
|
|
47
47
|
{ view: 'timeline', icon: Clock, label: 'Timeline' },
|
|
48
|
-
{ view: 'traffic', icon: Activity, label: 'Traffic' },
|
|
48
|
+
{ view: 'traffic', icon: Activity, label: 'Live Traffic' },
|
|
49
49
|
{ view: 'helm', icon: Package, label: 'Helm' },
|
|
50
50
|
{ view: 'gitops', icon: GitBranch, label: 'GitOps' },
|
|
51
51
|
{ view: 'checks', icon: ShieldCheck, label: 'Checks' },
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useState, useRef, useEffect } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
2
3
|
import { Plug, ChevronDown, Loader2, Globe, Monitor, Copy, Check, X, Terminal } from 'lucide-react'
|
|
3
4
|
import { clsx } from 'clsx'
|
|
4
|
-
import { useAvailablePorts,
|
|
5
|
+
import { useAvailablePorts, AvailablePort } from '../../api/client'
|
|
5
6
|
import { useStartPortForward } from './PortForwardManager'
|
|
7
|
+
import { useIsLocalDeployment } from '../../contexts/CapabilitiesContext'
|
|
6
8
|
import { validatePort } from '@skyhook-io/k8s-ui/utils/validators'
|
|
7
9
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
10
|
|
|
@@ -22,6 +24,13 @@ interface KubectlDialogInfo {
|
|
|
22
24
|
port: number
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
// kubectl port-forward (and the live forward, which uses the same transport) is
|
|
28
|
+
// TCP-only — UDP/SCTP can't be forwarded (kubernetes/kubernetes#47862). Treat an
|
|
29
|
+
// unset protocol as TCP.
|
|
30
|
+
export function isPortForwardable(protocol?: string): boolean {
|
|
31
|
+
return (protocol || 'TCP').toUpperCase() === 'TCP'
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
function buildKubectlCommand(type: 'pod' | 'service', namespace: string, name: string, localPort: number, remotePort: number) {
|
|
26
35
|
const resource = type === 'pod' ? `pod/${name}` : `svc/${name}`
|
|
27
36
|
const portArg = localPort === remotePort ? `${remotePort}` : `${localPort}:${remotePort}`
|
|
@@ -70,17 +79,23 @@ function KubectlCommandDialog({
|
|
|
70
79
|
|
|
71
80
|
useEffect(() => {
|
|
72
81
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
73
|
-
if (e.key
|
|
82
|
+
if (e.key !== 'Escape') return
|
|
83
|
+
// Capture + stopPropagation so Escape closes only this dialog, not the
|
|
84
|
+
// drawer behind it (whose Escape shortcut listens in the bubble phase).
|
|
85
|
+
e.stopPropagation()
|
|
86
|
+
onClose()
|
|
74
87
|
}
|
|
75
|
-
document.addEventListener('keydown', handleKeyDown)
|
|
76
|
-
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
88
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
89
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
77
90
|
}, [onClose])
|
|
78
91
|
|
|
79
92
|
useEffect(() => {
|
|
80
93
|
dialogRef.current?.focus()
|
|
81
94
|
}, [])
|
|
82
95
|
|
|
83
|
-
|
|
96
|
+
// Portal to <body>: the drawer is a transformed ancestor that would otherwise
|
|
97
|
+
// trap this position:fixed dialog inside the drawer instead of centering it.
|
|
98
|
+
return createPortal(
|
|
84
99
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
85
100
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
86
101
|
<div
|
|
@@ -103,7 +118,7 @@ function KubectlCommandDialog({
|
|
|
103
118
|
|
|
104
119
|
<div className="p-4 space-y-3">
|
|
105
120
|
<p className="text-sm text-theme-text-secondary">
|
|
106
|
-
|
|
121
|
+
Forward this port to your own machine — run it from your terminal:
|
|
107
122
|
</p>
|
|
108
123
|
<div className="flex flex-col gap-1">
|
|
109
124
|
<div className="flex items-center gap-2 text-sm text-theme-text-secondary">
|
|
@@ -147,12 +162,13 @@ function KubectlCommandDialog({
|
|
|
147
162
|
{copied ? 'Copied' : copyFallback ? 'Press Ctrl+C' : 'Copy'}
|
|
148
163
|
</button>
|
|
149
164
|
</div>
|
|
150
|
-
<p className="text-xs text-theme-text-
|
|
151
|
-
|
|
165
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
166
|
+
You'll need <code className="inline-code">kubectl</code> and access to this cluster.
|
|
152
167
|
</p>
|
|
153
168
|
</div>
|
|
154
169
|
</div>
|
|
155
|
-
</div
|
|
170
|
+
</div>,
|
|
171
|
+
document.body,
|
|
156
172
|
)
|
|
157
173
|
}
|
|
158
174
|
|
|
@@ -168,12 +184,16 @@ export function PortForwardButton({
|
|
|
168
184
|
const [listenAddress, setListenAddress] = useState<'127.0.0.1' | '0.0.0.0'>('127.0.0.1')
|
|
169
185
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
170
186
|
|
|
171
|
-
const
|
|
187
|
+
const isLocal = useIsLocalDeployment()
|
|
172
188
|
const { data, isLoading } = useAvailablePorts(type, namespace, name)
|
|
173
189
|
const startPortForward = useStartPortForward()
|
|
174
190
|
|
|
175
191
|
const ports = data?.ports || []
|
|
176
|
-
|
|
192
|
+
// Decide copy-command vs live forward from the SAME deployment signal that
|
|
193
|
+
// gates whether the button shows at all — so the two can't disagree (and we
|
|
194
|
+
// don't race a separate /cluster-info fetch that defaults to "not in-cluster").
|
|
195
|
+
// Cloud runs in-cluster too, so anything not-local uses the copy command.
|
|
196
|
+
const inCluster = !isLocal
|
|
177
197
|
const isPending = !inCluster && startPortForward.isPending
|
|
178
198
|
const resourceName = type === 'service' ? (serviceName || name) : name
|
|
179
199
|
|
|
@@ -204,10 +224,15 @@ export function PortForwardButton({
|
|
|
204
224
|
}
|
|
205
225
|
|
|
206
226
|
function renderButton() {
|
|
207
|
-
//
|
|
208
|
-
|
|
227
|
+
// kubectl port-forward is TCP-only — never offer UDP/SCTP ports as targets.
|
|
228
|
+
const forwardable = ports.filter((p) => isPortForwardable(p.protocol))
|
|
229
|
+
|
|
230
|
+
// No forwardable ports: disabled button. Distinguish "no ports at all" from
|
|
231
|
+
// "ports exist but are all UDP" so the operator isn't left guessing.
|
|
232
|
+
if (!isLoading && forwardable.length === 0) {
|
|
233
|
+
const udpOnly = ports.length > 0
|
|
209
234
|
return (
|
|
210
|
-
<Tooltip content="No ports available
|
|
235
|
+
<Tooltip content={udpOnly ? "kubectl port-forward doesn't support UDP" : 'No ports available'}>
|
|
211
236
|
<button
|
|
212
237
|
disabled
|
|
213
238
|
className={clsx(
|
|
@@ -216,18 +241,18 @@ export function PortForwardButton({
|
|
|
216
241
|
)}
|
|
217
242
|
>
|
|
218
243
|
<Plug className="w-4 h-4" />
|
|
219
|
-
No Ports
|
|
244
|
+
{udpOnly ? 'No TCP Ports' : 'No Ports'}
|
|
220
245
|
</button>
|
|
221
246
|
</Tooltip>
|
|
222
247
|
)
|
|
223
248
|
}
|
|
224
249
|
|
|
225
|
-
// If only one port, forward directly on click (most common case)
|
|
226
|
-
if (
|
|
250
|
+
// If only one forwardable port, forward directly on click (most common case)
|
|
251
|
+
if (forwardable.length === 1) {
|
|
227
252
|
return (
|
|
228
|
-
<Tooltip content={`Port forward to ${
|
|
253
|
+
<Tooltip content={`Port forward to ${forwardable[0].port}`}>
|
|
229
254
|
<button
|
|
230
|
-
onClick={() => handlePortSelect(
|
|
255
|
+
onClick={() => handlePortSelect(forwardable[0])}
|
|
231
256
|
disabled={isPending}
|
|
232
257
|
className={clsx(
|
|
233
258
|
'flex items-center gap-2 px-3 py-2 bg-theme-elevated text-theme-text-primary text-sm rounded-lg hover:bg-theme-hover transition-colors disabled:opacity-50 disabled:pointer-events-none',
|
|
@@ -239,7 +264,7 @@ export function PortForwardButton({
|
|
|
239
264
|
) : (
|
|
240
265
|
<Plug className="w-4 h-4" />
|
|
241
266
|
)}
|
|
242
|
-
Forward :{
|
|
267
|
+
Forward :{forwardable[0].port}
|
|
243
268
|
</button>
|
|
244
269
|
</Tooltip>
|
|
245
270
|
)
|
|
@@ -306,7 +331,7 @@ export function PortForwardButton({
|
|
|
306
331
|
<div className="px-2 py-1.5 text-xs text-theme-text-disabled border-b border-theme-border">
|
|
307
332
|
Select port to forward
|
|
308
333
|
</div>
|
|
309
|
-
{
|
|
334
|
+
{forwardable.map((port, i) => (
|
|
310
335
|
<button
|
|
311
336
|
key={i}
|
|
312
337
|
onClick={() => handlePortSelect(port)}
|
|
@@ -355,11 +380,15 @@ export function PortForwardInlineButton({
|
|
|
355
380
|
protocol = 'TCP',
|
|
356
381
|
disabled = false,
|
|
357
382
|
}: PortForwardInlineButtonProps) {
|
|
358
|
-
const
|
|
383
|
+
const isLocal = useIsLocalDeployment()
|
|
359
384
|
const startPortForward = useStartPortForward()
|
|
360
385
|
const [dialogInfo, setDialogInfo] = useState<KubectlDialogInfo | null>(null)
|
|
361
386
|
|
|
362
|
-
|
|
387
|
+
// Decide copy-command vs live forward from the SAME deployment signal that
|
|
388
|
+
// gates whether the button shows at all — so the two can't disagree (and we
|
|
389
|
+
// don't race a separate /cluster-info fetch that defaults to "not in-cluster").
|
|
390
|
+
// Cloud runs in-cluster too, so anything not-local uses the copy command.
|
|
391
|
+
const inCluster = !isLocal
|
|
363
392
|
const isPending = !inCluster && startPortForward.isPending
|
|
364
393
|
|
|
365
394
|
const handleClick = (e: React.MouseEvent) => {
|
|
@@ -378,15 +407,30 @@ export function PortForwardInlineButton({
|
|
|
378
407
|
}
|
|
379
408
|
}
|
|
380
409
|
|
|
410
|
+
// UDP/SCTP can't be port-forwarded — show a muted, non-interactive hint that
|
|
411
|
+
// explains why rather than a button that would copy a command that can't work.
|
|
412
|
+
if (!isPortForwardable(protocol)) {
|
|
413
|
+
return (
|
|
414
|
+
<Tooltip content="kubectl port-forward doesn't support UDP">
|
|
415
|
+
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-theme-elevated rounded text-xs text-theme-text-tertiary opacity-60 cursor-default">
|
|
416
|
+
{port}/{protocol}
|
|
417
|
+
<Plug className="w-3 h-3" />
|
|
418
|
+
</span>
|
|
419
|
+
</Tooltip>
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
381
423
|
return (
|
|
382
424
|
<>
|
|
383
|
-
<Tooltip content={`Port forward ${port}`}>
|
|
425
|
+
<Tooltip content={inCluster ? 'Copy a kubectl port-forward command' : `Port forward ${port}`}>
|
|
384
426
|
<button
|
|
385
427
|
onClick={handleClick}
|
|
386
428
|
disabled={disabled || isPending}
|
|
387
429
|
className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-theme-elevated hover:bg-accent-muted rounded text-xs transition-colors disabled:opacity-50 disabled:hover:bg-theme-elevated disabled:pointer-events-none"
|
|
388
430
|
>
|
|
389
|
-
{
|
|
431
|
+
{/* In-cluster this opens a copy-command dialog rather than forwarding now;
|
|
432
|
+
the trailing "…" signals "opens a dialog" (it doesn't fire immediately). */}
|
|
433
|
+
{port}/{protocol}{inCluster ? '…' : ''}
|
|
390
434
|
{isPending ? (
|
|
391
435
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
392
436
|
) : (
|
|
@@ -31,7 +31,7 @@ import { Tooltip } from '../ui/Tooltip'
|
|
|
31
31
|
import { useToast } from '../ui/Toast'
|
|
32
32
|
import { openExternal } from '../../utils/navigation'
|
|
33
33
|
import { apiUrl } from '../../api/config'
|
|
34
|
-
import { apiFetch } from '../../api/client'
|
|
34
|
+
import { apiFetch, useCapabilities } from '../../api/client'
|
|
35
35
|
import { pluralize } from '@skyhook-io/k8s-ui'
|
|
36
36
|
|
|
37
37
|
// --- Types -------------------------------------------------------------------
|
|
@@ -84,7 +84,7 @@ function buildRecreateBody(session: PortForwardSession, overrides: { localPort:
|
|
|
84
84
|
|
|
85
85
|
// --- Shared query ------------------------------------------------------------
|
|
86
86
|
|
|
87
|
-
function usePortForwardQuery() {
|
|
87
|
+
function usePortForwardQuery(enabled: boolean) {
|
|
88
88
|
return useQuery<PortForwardSession[]>({
|
|
89
89
|
queryKey: ['portforwards'],
|
|
90
90
|
queryFn: async () => {
|
|
@@ -92,6 +92,10 @@ function usePortForwardQuery() {
|
|
|
92
92
|
if (!res.ok) throw new Error('Failed to fetch port forwards')
|
|
93
93
|
return res.json()
|
|
94
94
|
},
|
|
95
|
+
// Port-forward is a local-binary feature; in-cluster (Radar Cloud) the
|
|
96
|
+
// capability is false, so don't poll an endpoint that can never return a
|
|
97
|
+
// usable session. Also covers RBAC-denied users.
|
|
98
|
+
enabled,
|
|
95
99
|
// 30s fallback poll — user mutations invalidate immediately, but out-of-band
|
|
96
100
|
// session death (pod restart, OOM kill, server-side cleanup) only surfaces on
|
|
97
101
|
// the next tick.
|
|
@@ -144,12 +148,21 @@ interface PortForwardContextValue {
|
|
|
144
148
|
const PortForwardContext = createContext<PortForwardContextValue | null>(null)
|
|
145
149
|
|
|
146
150
|
export function PortForwardProvider({ children }: { children: ReactNode }) {
|
|
151
|
+
// Gate the session-list poll on runtime mode, not the RBAC capability: port-forward
|
|
152
|
+
// only works when radar runs as a local binary, so in-cluster (Radar Cloud) we never
|
|
153
|
+
// poll /portforwards. We deliberately do NOT gate on `portForward` (RBAC) — a local
|
|
154
|
+
// user with portforward rights in only some namespaces must still see/stop the
|
|
155
|
+
// sessions they start (the start buttons gate per-namespace separately). Using the
|
|
156
|
+
// resolved value (undefined while capabilities load → no poll) keeps Cloud silent on
|
|
157
|
+
// first paint.
|
|
158
|
+
const { data: caps } = useCapabilities()
|
|
159
|
+
const canPortForward = caps?.deployment?.mode === 'local'
|
|
147
160
|
const {
|
|
148
161
|
data: sessions = [],
|
|
149
162
|
isLoading,
|
|
150
163
|
isError: isQueryError,
|
|
151
164
|
error: queryError,
|
|
152
|
-
} = usePortForwardQuery()
|
|
165
|
+
} = usePortForwardQuery(canPortForward)
|
|
153
166
|
const activeSessions = sessions.filter((s) => s.status !== 'stopped')
|
|
154
167
|
const errorSessions = sessions.filter((s) => s.status === 'error')
|
|
155
168
|
const count = activeSessions.length
|
|
@@ -970,6 +983,7 @@ export function useStartPortForward() {
|
|
|
970
983
|
|
|
971
984
|
// Backwards-compat: existing consumers that just want a count number.
|
|
972
985
|
export function usePortForwardCount() {
|
|
973
|
-
const { data:
|
|
986
|
+
const { data: caps } = useCapabilities()
|
|
987
|
+
const { data: sessions = [] } = usePortForwardQuery(caps?.deployment?.mode === 'local')
|
|
974
988
|
return sessions.filter((s) => s.status !== 'stopped').length
|
|
975
989
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { useState, useMemo, useCallback, useEffect } from 'react'
|
|
2
2
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
3
3
|
import { useQuery } from '@tanstack/react-query'
|
|
4
|
-
import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads } from '../../api/client'
|
|
4
|
+
import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads, useAudit } from '../../api/client'
|
|
5
|
+
import { isBadgeWorthy } from '../../utils/auditBadges'
|
|
6
|
+
import type { AuditBadgeMessage } from '@skyhook-io/k8s-ui'
|
|
5
7
|
import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
|
|
6
8
|
import { useAPIResources } from '../../api/apiResources'
|
|
7
9
|
import { initNavigationMap } from '@skyhook-io/k8s-ui'
|
|
@@ -137,6 +139,44 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
137
139
|
return match?.isCrd ?? (!!selectedKind.group) // default: has group = likely CRD
|
|
138
140
|
}, [selectedKind, apiResources])
|
|
139
141
|
|
|
142
|
+
// The canonical Kind for the selected resource. selectedKind.kind is the plural
|
|
143
|
+
// URL segment for CRDs/grouped kinds (e.g. "ingressroutes", "ingresses") — only
|
|
144
|
+
// core no-group kinds resolve to the real Kind there — so resolve it via
|
|
145
|
+
// discovery to match audit findings, which key by the real Kind ("IngressRoute").
|
|
146
|
+
const selectedKindCanonical = useMemo(() => {
|
|
147
|
+
if (!selectedKind) return undefined
|
|
148
|
+
const match = apiResources?.find(r => r.name === selectedKind.name && r.group === selectedKind.group)
|
|
149
|
+
?? CORE_RESOURCES.find(r => r.name === selectedKind.name && r.group === selectedKind.group)
|
|
150
|
+
return match?.kind ?? selectedKind.kind
|
|
151
|
+
}, [selectedKind, apiResources])
|
|
152
|
+
|
|
153
|
+
// Cluster Audit findings for the selected kind, keyed by "namespace/name" for
|
|
154
|
+
// the resource list. The list shows ONE kind at a time, so ns/name is enough;
|
|
155
|
+
// we still match the finding's group (built-ins → real group, CRDs → "") so a
|
|
156
|
+
// kind shared across groups doesn't bleed findings across the two lists. Only
|
|
157
|
+
// "badge-worthy" findings count (reference-integrity / lifecycle) — posture
|
|
158
|
+
// and best-practice nags fire near-universally and would just be noise.
|
|
159
|
+
const audit = useAudit(namespaces)
|
|
160
|
+
const auditBadges = useMemo(() => {
|
|
161
|
+
if (!selectedKind || !audit.data?.findings) return undefined
|
|
162
|
+
const wantGroup = isSelectedCrd ? '' : selectedKind.group
|
|
163
|
+
const map: Record<string, { danger: number; warning: number; messages: AuditBadgeMessage[] }> = {}
|
|
164
|
+
for (const f of audit.data.findings) {
|
|
165
|
+
if (f.kind !== selectedKindCanonical || (f.group ?? '') !== wantGroup) continue
|
|
166
|
+
if (!isBadgeWorthy(f, audit.data.checks)) continue
|
|
167
|
+
const k = `${f.namespace || ''}/${f.name}`
|
|
168
|
+
const cur = map[k] ?? { danger: 0, warning: 0, messages: [] }
|
|
169
|
+
if (f.severity === 'danger') cur.danger++
|
|
170
|
+
else if (f.severity === 'warning') cur.warning++
|
|
171
|
+
cur.messages.push({ severity: f.severity, message: f.message })
|
|
172
|
+
map[k] = cur
|
|
173
|
+
}
|
|
174
|
+
for (const cur of Object.values(map)) {
|
|
175
|
+
cur.messages.sort((a, b) => (a.severity === 'danger' ? 0 : 1) - (b.severity === 'danger' ? 0 : 1))
|
|
176
|
+
}
|
|
177
|
+
return map
|
|
178
|
+
}, [audit.data?.findings, audit.data?.checks, selectedKind, selectedKindCanonical, isSelectedCrd])
|
|
179
|
+
|
|
140
180
|
const selectedCountKey = selectedKind ? resourceCountKey(selectedKind) : ''
|
|
141
181
|
const selectedCount = selectedCountKey ? countsData?.counts[selectedCountKey] : undefined
|
|
142
182
|
const selectedCountUnavailable = selectedCountKey ? countsData?.unavailable?.includes(selectedCountKey) ?? false : false
|
|
@@ -294,6 +334,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
294
334
|
topNodeMetrics={topNodeMetrics}
|
|
295
335
|
certExpiry={certExpiry}
|
|
296
336
|
certExpiryError={certExpiryError}
|
|
337
|
+
auditBadges={auditBadges}
|
|
297
338
|
// Pinned kinds
|
|
298
339
|
pinned={pinned}
|
|
299
340
|
togglePin={togglePin}
|
|
@@ -2,7 +2,7 @@ import { PodRenderer as BasePodRenderer } from '@skyhook-io/k8s-ui/components/re
|
|
|
2
2
|
import type { CopyHandler } from '@skyhook-io/k8s-ui/components/ui/drawer-components'
|
|
3
3
|
import type { ResolvedEnvFrom } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import { useOpenTerminal, useOpenLogs } from '../../dock'
|
|
5
|
-
import { useNamespacedCapabilities } from '../../../contexts/CapabilitiesContext'
|
|
5
|
+
import { useNamespacedCapabilities, useIsLocalDeployment } from '../../../contexts/CapabilitiesContext'
|
|
6
6
|
import { usePodMetrics, usePodMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
|
|
7
7
|
import { useRBACSubject } from '../../../api/rbac'
|
|
8
8
|
import { PortForwardInlineButton } from '../../portforward/PortForwardButton'
|
|
@@ -27,6 +27,11 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
|
|
|
27
27
|
|
|
28
28
|
// Capabilities (namespace-scoped: re-checks RBAC if globally denied)
|
|
29
29
|
const { canExec, canViewLogs, canPortForward } = useNamespacedCapabilities(namespace)
|
|
30
|
+
// Show the port-forward affordance for a live forward (local + RBAC) OR when
|
|
31
|
+
// not local — in-cluster/Cloud surfaces a copy-paste kubectl command instead.
|
|
32
|
+
// The button itself picks live vs. copy-command based on deployment mode.
|
|
33
|
+
const isLocal = useIsLocalDeployment()
|
|
34
|
+
const showPortForward = canPortForward || !isLocal
|
|
30
35
|
|
|
31
36
|
// Metrics
|
|
32
37
|
const { data: metrics } = usePodMetrics(namespace, podName)
|
|
@@ -65,7 +70,7 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
|
|
|
65
70
|
rbacError={rbacError as Error | null}
|
|
66
71
|
canExec={canExec}
|
|
67
72
|
canViewLogs={canViewLogs}
|
|
68
|
-
canPortForward={
|
|
73
|
+
canPortForward={showPortForward}
|
|
69
74
|
onOpenTerminal={(params) => openTerminal(params)}
|
|
70
75
|
onOpenLogsPanel={(params) => openLogsPanel(params)}
|
|
71
76
|
renderPortAction={({ namespace: ns, podName: pod, port, protocol, disabled }) => (
|