@skyhook-io/radar-app 1.8.0 → 1.8.2
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 +2 -2
- package/src/App.tsx +95 -16
- package/src/api/client.ts +64 -3
- package/src/components/DebugOverlay.tsx +1 -1
- package/src/components/applications/ApplicationsView.tsx +1 -1
- package/src/components/compare/CompareViewRoute.tsx +13 -5
- package/src/components/cost/CostTrendChart.tsx +3 -3
- package/src/components/cost/CostView.tsx +1 -1
- package/src/components/helm/ChartBrowser.tsx +1 -1
- package/src/components/helm/HelmReleaseDrawer.tsx +233 -19
- package/src/components/helm/HelmView.tsx +85 -16
- package/src/components/helm/InstallWizard.tsx +1 -1
- package/src/components/helm/RevisionHistory.tsx +46 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +5 -5
- package/src/components/issues/IssuesPane.tsx +2 -2
- package/src/components/nav/PrimaryNavRail.tsx +1 -1
- package/src/components/portforward/PortForwardManager.tsx +70 -8
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +2 -0
- package/src/components/settings/MyPermissionsDialog.tsx +64 -4
- package/src/components/shared/LargeClusterNamespacePicker.tsx +1 -1
- package/src/components/traffic/TrafficGraph.tsx +29 -20
- package/src/components/traffic/TrafficView.tsx +3 -3
- package/src/components/ui/DiagnosticsOverlay.tsx +1 -1
- package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
- package/src/components/ui/command-items.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +17 -17
- package/src/context/ConnectionContext.tsx +29 -2
- package/src/main.tsx +1 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
|
|
3
|
+
import { X, Plus, Trash2, Link2, AlertTriangle } from 'lucide-react'
|
|
4
|
+
import { clsx } from 'clsx'
|
|
5
|
+
import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo } from '../../api/client'
|
|
6
|
+
|
|
7
|
+
interface TrackChartSourceDialogProps {
|
|
8
|
+
open: boolean
|
|
9
|
+
onClose: () => void
|
|
10
|
+
/** Chart name of the release this was opened from, for the example prompt. */
|
|
11
|
+
chartName?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// TrackChartSourceDialog lets the user register an OCI chart-source prefix — the
|
|
15
|
+
// OCI analog of `helm repo add`. Helm doesn't persist the ref a release was
|
|
16
|
+
// installed from, so for charts published to an OCI registry (and not managed by
|
|
17
|
+
// GitOps) Radar can only track upgrades once the user declares where they live.
|
|
18
|
+
// Registering a registry/org prefix lets Radar probe "<prefix>/<chartName>".
|
|
19
|
+
export function TrackChartSourceDialog({ open, onClose, chartName }: TrackChartSourceDialogProps) {
|
|
20
|
+
const [value, setValue] = useState('')
|
|
21
|
+
const { data: sources } = useHelmOCISources()
|
|
22
|
+
const { data: clusterInfo } = useClusterInfo()
|
|
23
|
+
const addSource = useAddOCISource()
|
|
24
|
+
const removeSource = useRemoveOCISource()
|
|
25
|
+
|
|
26
|
+
// In-cluster Radar has no `helm registry login` store (the pod's HELM_CONFIG_HOME
|
|
27
|
+
// points at an empty /tmp), so private registries can't authenticate — only
|
|
28
|
+
// public charts can be tracked. Be honest about it rather than silently failing.
|
|
29
|
+
const inCluster = clusterInfo?.inCluster ?? false
|
|
30
|
+
|
|
31
|
+
const trimmed = value.trim()
|
|
32
|
+
const invalid = trimmed !== '' && !trimmed.startsWith('oci://')
|
|
33
|
+
|
|
34
|
+
const handleAdd = () => {
|
|
35
|
+
if (!trimmed || invalid) return
|
|
36
|
+
addSource.mutate(trimmed, { onSuccess: () => setValue('') })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<DialogPortal open={open} onClose={onClose} className="max-w-lg w-full">
|
|
41
|
+
<div className="flex items-start gap-3 p-4 border-b border-theme-border">
|
|
42
|
+
<div className="flex items-center justify-center w-10 h-10 rounded-full shrink-0 bg-theme-hover">
|
|
43
|
+
<Link2 className="w-5 h-5 text-theme-text-secondary" />
|
|
44
|
+
</div>
|
|
45
|
+
<div className="flex-1 min-w-0">
|
|
46
|
+
<h3 className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
|
|
47
|
+
<p className="text-sm text-theme-text-secondary mt-1">
|
|
48
|
+
Helm doesn't record where a chart was installed from. Register your OCI
|
|
49
|
+
registry prefix and Radar will check it for newer versions of your charts.
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
<button
|
|
53
|
+
onClick={onClose}
|
|
54
|
+
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
55
|
+
>
|
|
56
|
+
<X className="w-5 h-5" />
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div className="p-4 space-y-4">
|
|
61
|
+
<div>
|
|
62
|
+
<label className="block text-sm font-medium text-theme-text-secondary mb-2">
|
|
63
|
+
OCI registry prefix
|
|
64
|
+
</label>
|
|
65
|
+
<div className="flex gap-2">
|
|
66
|
+
<input
|
|
67
|
+
type="text"
|
|
68
|
+
value={value}
|
|
69
|
+
onChange={(e) => setValue(e.target.value)}
|
|
70
|
+
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
|
71
|
+
placeholder="oci://ghcr.io/myorg/charts"
|
|
72
|
+
aria-invalid={invalid ? true : undefined}
|
|
73
|
+
className={clsx(
|
|
74
|
+
'flex-1 px-3 py-2 bg-theme-elevated border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2',
|
|
75
|
+
invalid ? 'border-red-500/60 focus:ring-red-500' : 'border-theme-border-light focus:ring-accent',
|
|
76
|
+
)}
|
|
77
|
+
/>
|
|
78
|
+
<button
|
|
79
|
+
onClick={handleAdd}
|
|
80
|
+
disabled={!trimmed || invalid || addSource.isPending}
|
|
81
|
+
className="btn-brand px-3 py-2 text-sm inline-flex items-center gap-1 disabled:opacity-50 disabled:pointer-events-none"
|
|
82
|
+
>
|
|
83
|
+
<Plus className="w-4 h-4" />
|
|
84
|
+
Add
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
88
|
+
{invalid
|
|
89
|
+
? 'Must be an oci:// reference.'
|
|
90
|
+
: chartName
|
|
91
|
+
? `Radar will look for "${chartName}" under this prefix (and any others below).`
|
|
92
|
+
: 'Radar probes <prefix>/<chartName> for each untracked release.'}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{sources && sources.length > 0 && (
|
|
97
|
+
<div>
|
|
98
|
+
<p className="text-xs font-medium text-theme-text-tertiary uppercase tracking-wide mb-2">
|
|
99
|
+
Registered sources
|
|
100
|
+
</p>
|
|
101
|
+
<ul className="space-y-1">
|
|
102
|
+
{sources.map((src) => (
|
|
103
|
+
<li
|
|
104
|
+
key={src}
|
|
105
|
+
className="flex items-center justify-between gap-2 px-3 py-2 bg-theme-elevated rounded-lg"
|
|
106
|
+
>
|
|
107
|
+
<span className="text-sm text-theme-text-primary font-mono truncate">{src}</span>
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => removeSource.mutate(src)}
|
|
110
|
+
disabled={removeSource.isPending}
|
|
111
|
+
className="p-1 text-theme-text-secondary hover:text-red-400 hover:bg-red-500/10 rounded disabled:opacity-50"
|
|
112
|
+
aria-label={`Remove ${src}`}
|
|
113
|
+
>
|
|
114
|
+
<Trash2 className="w-4 h-4" />
|
|
115
|
+
</button>
|
|
116
|
+
</li>
|
|
117
|
+
))}
|
|
118
|
+
</ul>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{inCluster ? (
|
|
123
|
+
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
|
|
124
|
+
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
|
125
|
+
<span>
|
|
126
|
+
Radar is running in-cluster, where it has no{' '}
|
|
127
|
+
<span className="font-mono">helm registry login</span> credentials — only{' '}
|
|
128
|
+
<strong>public</strong> charts can be tracked. Private-registry support for in-cluster
|
|
129
|
+
Radar isn't available yet.
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
) : (
|
|
133
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
134
|
+
Credentials are reused from your <span className="font-mono">helm registry login</span>.
|
|
135
|
+
Radar stores no registry secrets.
|
|
136
|
+
</p>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
</DialogPortal>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
@@ -82,6 +82,9 @@ export function ActivitySummary({ namespaces, topology, onNavigate }: ActivitySu
|
|
|
82
82
|
limit: 1000,
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
// Intentionally re-sample 'now' only when events refresh (not every render),
|
|
86
|
+
// so the timeline window stays stable between data updates.
|
|
87
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
88
|
const now = useMemo(() => Date.now(), [events])
|
|
86
89
|
const spanMs = SPAN_MINUTES * 60 * 1000
|
|
87
90
|
const startTime = now - spanMs
|
|
@@ -125,7 +128,7 @@ export function ActivitySummary({ namespaces, topology, onNavigate }: ActivitySu
|
|
|
125
128
|
<div className="flex-1 min-h-0 overflow-hidden px-4 py-1.5">
|
|
126
129
|
{isLoading ? (
|
|
127
130
|
<div className="flex items-center justify-center h-full py-4 text-xs text-theme-text-tertiary">
|
|
128
|
-
Loading
|
|
131
|
+
Loading…
|
|
129
132
|
</div>
|
|
130
133
|
) : error ? (
|
|
131
134
|
<div className="flex items-center justify-center h-full py-4 text-xs text-theme-text-tertiary">
|
|
@@ -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>
|
|
@@ -126,7 +126,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
name: 'get_changes',
|
|
129
|
-
desc: 'Recent resource creates, updates, and deletes from the
|
|
129
|
+
desc: 'Recent resource creates, updates, and deletes from the Kubernetes timeline. Helm release history is separate; use list_helm_releases or get_helm_release include=history,operations for failed upgrades and rollbacks.',
|
|
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,16 +147,16 @@ 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, and current lastOperation; include history and operations for the full revision trail.',
|
|
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' },
|
|
159
|
+
{ arg: 'include', desc: 'values, history, operations, diff' },
|
|
160
160
|
{ arg: 'diff_revision_1', desc: 'first revision for diff' },
|
|
161
161
|
{ arg: 'diff_revision_2', desc: 'second revision for diff (defaults to current)' },
|
|
162
162
|
],
|
|
@@ -33,7 +33,7 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
|
|
|
33
33
|
const { data, isLoading, error } = useIssues(namespaces)
|
|
34
34
|
const [severityFilter, setSeverityFilter] = useState<Set<IssueSeverity>>(new Set())
|
|
35
35
|
|
|
36
|
-
const allIssues = data?.issues ?? []
|
|
36
|
+
const allIssues = useMemo(() => data?.issues ?? [], [data])
|
|
37
37
|
const totals = useMemo(() => {
|
|
38
38
|
const t: Record<IssueSeverity, number> = { critical: 0, warning: 0 }
|
|
39
39
|
for (const i of allIssues) t[i.severity] = (t[i.severity] ?? 0) + 1
|
|
@@ -44,7 +44,7 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
|
|
|
44
44
|
const toggleSeverity = (s: IssueSeverity) =>
|
|
45
45
|
setSeverityFilter((prev) => {
|
|
46
46
|
const next = new Set(prev)
|
|
47
|
-
next.has(s)
|
|
47
|
+
if (next.has(s)) next.delete(s); else next.add(s)
|
|
48
48
|
return next
|
|
49
49
|
})
|
|
50
50
|
|
|
@@ -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' },
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
Globe,
|
|
21
21
|
Monitor,
|
|
22
22
|
PenLine,
|
|
23
|
+
RotateCw,
|
|
23
24
|
} from 'lucide-react'
|
|
24
25
|
import { clsx } from 'clsx'
|
|
25
26
|
// CSS_EASE (the shared spring curve) is intentionally NOT used for this panel —
|
|
@@ -30,6 +31,7 @@ import { Tooltip } from '../ui/Tooltip'
|
|
|
30
31
|
import { useToast } from '../ui/Toast'
|
|
31
32
|
import { openExternal } from '../../utils/navigation'
|
|
32
33
|
import { apiUrl } from '../../api/config'
|
|
34
|
+
import { apiFetch } from '../../api/client'
|
|
33
35
|
import { pluralize } from '@skyhook-io/k8s-ui'
|
|
34
36
|
|
|
35
37
|
// --- Types -------------------------------------------------------------------
|
|
@@ -86,7 +88,7 @@ function usePortForwardQuery() {
|
|
|
86
88
|
return useQuery<PortForwardSession[]>({
|
|
87
89
|
queryKey: ['portforwards'],
|
|
88
90
|
queryFn: async () => {
|
|
89
|
-
const res = await
|
|
91
|
+
const res = await apiFetch(apiUrl('/portforwards'))
|
|
90
92
|
if (!res.ok) throw new Error('Failed to fetch port forwards')
|
|
91
93
|
return res.json()
|
|
92
94
|
},
|
|
@@ -390,6 +392,9 @@ export function PortForwardPanel() {
|
|
|
390
392
|
// without disabling all stop buttons (the old shared-mutation approach blocked
|
|
391
393
|
// every row when any single stop was in-flight).
|
|
392
394
|
const [stoppingIds, setStoppingIds] = useState<Set<string>>(() => new Set())
|
|
395
|
+
// Per-session retry tracking — same rationale as stoppingIds: multiple failed
|
|
396
|
+
// forwards can be retried independently without disabling every retry button.
|
|
397
|
+
const [retryingIds, setRetryingIds] = useState<Set<string>>(() => new Set())
|
|
393
398
|
const queryClient = useQueryClient()
|
|
394
399
|
const { showSuccess, showError } = useToast()
|
|
395
400
|
|
|
@@ -424,7 +429,7 @@ export function PortForwardPanel() {
|
|
|
424
429
|
const stopPortForward = useCallback(async (id: string) => {
|
|
425
430
|
setStoppingIds(prev => new Set(prev).add(id))
|
|
426
431
|
try {
|
|
427
|
-
const res = await
|
|
432
|
+
const res = await apiFetch(apiUrl(`/portforwards/${id}`), { method: 'DELETE' })
|
|
428
433
|
if (!res.ok) {
|
|
429
434
|
const body = await res.json().catch(() => ({}))
|
|
430
435
|
throw new Error(body.error || `Failed to stop port forward (HTTP ${res.status})`)
|
|
@@ -444,6 +449,48 @@ export function PortForwardPanel() {
|
|
|
444
449
|
}
|
|
445
450
|
}, [queryClient, showError])
|
|
446
451
|
|
|
452
|
+
// Recreate a failed forward. The errored session is already dead — there's no live
|
|
453
|
+
// forward to lose — so we drop the stale row FIRST, then recreate. Delete-first keeps
|
|
454
|
+
// the panel at exactly one row in every outcome (success → one running row; failure →
|
|
455
|
+
// one errored row), avoiding the orphaned-duplicate the reverse order would leave when
|
|
456
|
+
// the backend keeps a failed-start session in its map. A 404 means it was already
|
|
457
|
+
// cleared (e.g. context switch) — benign, proceed. Service-resolved sessions re-route
|
|
458
|
+
// through the service path via buildRecreateBody, so a retry after the backing pod was
|
|
459
|
+
// replaced re-resolves to a currently-running pod.
|
|
460
|
+
const retryPortForward = useCallback(async (session: PortForwardSession) => {
|
|
461
|
+
commitInteraction()
|
|
462
|
+
setRetryingIds(prev => new Set(prev).add(session.id))
|
|
463
|
+
try {
|
|
464
|
+
const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
|
|
465
|
+
if (!delRes.ok && delRes.status !== 404) {
|
|
466
|
+
const body = await delRes.json().catch(() => ({}))
|
|
467
|
+
throw new Error(body.error || `Failed to clear failed port forward (HTTP ${delRes.status})`)
|
|
468
|
+
}
|
|
469
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
470
|
+
method: 'POST',
|
|
471
|
+
headers: { 'Content-Type': 'application/json' },
|
|
472
|
+
body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: session.listenAddress })),
|
|
473
|
+
})
|
|
474
|
+
if (!res.ok) {
|
|
475
|
+
const body = await res.json().catch(() => ({}))
|
|
476
|
+
throw new Error(body.error || `Failed to retry port forward (HTTP ${res.status})`)
|
|
477
|
+
}
|
|
478
|
+
queryClient.invalidateQueries({ queryKey: ['portforwards'] })
|
|
479
|
+
showSuccess('Port forward restarted', `Now listening on localhost:${session.localPort}`)
|
|
480
|
+
} catch (err) {
|
|
481
|
+
queryClient.invalidateQueries({ queryKey: ['portforwards'] })
|
|
482
|
+
const msg = err instanceof Error ? err.message : 'Failed to retry port forward'
|
|
483
|
+
showError('Failed to retry port forward', msg)
|
|
484
|
+
console.error('Failed to retry port forward:', err)
|
|
485
|
+
} finally {
|
|
486
|
+
setRetryingIds(prev => {
|
|
487
|
+
const next = new Set(prev)
|
|
488
|
+
next.delete(session.id)
|
|
489
|
+
return next
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
}, [commitInteraction, queryClient, showSuccess, showError])
|
|
493
|
+
|
|
447
494
|
const toggleListenAddress = async (session: PortForwardSession) => {
|
|
448
495
|
commitInteraction()
|
|
449
496
|
const newAddress = session.listenAddress === '0.0.0.0' ? '127.0.0.1' : '0.0.0.0'
|
|
@@ -454,13 +501,13 @@ export function PortForwardPanel() {
|
|
|
454
501
|
// apart from "original gone and recreate failed = data loss."
|
|
455
502
|
let deleted = false
|
|
456
503
|
try {
|
|
457
|
-
const delRes = await
|
|
504
|
+
const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
|
|
458
505
|
if (!delRes.ok) {
|
|
459
506
|
const body = await delRes.json().catch(() => ({}))
|
|
460
507
|
throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
|
|
461
508
|
}
|
|
462
509
|
deleted = true
|
|
463
|
-
const res = await
|
|
510
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
464
511
|
method: 'POST',
|
|
465
512
|
headers: { 'Content-Type': 'application/json' },
|
|
466
513
|
body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: newAddress })),
|
|
@@ -501,13 +548,13 @@ export function PortForwardPanel() {
|
|
|
501
548
|
// apart from "original gone and recreate failed = data loss."
|
|
502
549
|
let deleted = false
|
|
503
550
|
try {
|
|
504
|
-
const delRes = await
|
|
551
|
+
const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
|
|
505
552
|
if (!delRes.ok) {
|
|
506
553
|
const body = await delRes.json().catch(() => ({}))
|
|
507
554
|
throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
|
|
508
555
|
}
|
|
509
556
|
deleted = true
|
|
510
|
-
const res = await
|
|
557
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
511
558
|
method: 'POST',
|
|
512
559
|
headers: { 'Content-Type': 'application/json' },
|
|
513
560
|
body: JSON.stringify(buildRecreateBody(session, { localPort: newPort, listenAddress: session.listenAddress })),
|
|
@@ -716,13 +763,28 @@ export function PortForwardPanel() {
|
|
|
716
763
|
</button>
|
|
717
764
|
</Tooltip>
|
|
718
765
|
)}
|
|
766
|
+
{session.status === 'error' && (
|
|
767
|
+
<Tooltip content="Retry" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
768
|
+
<button
|
|
769
|
+
onClick={() => retryPortForward(session)}
|
|
770
|
+
disabled={retryingIds.has(session.id) || stoppingIds.has(session.id)}
|
|
771
|
+
className="p-1.5 text-theme-text-tertiary hover:text-green-400 hover:bg-theme-hover rounded disabled:opacity-50"
|
|
772
|
+
>
|
|
773
|
+
{retryingIds.has(session.id) ? (
|
|
774
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
775
|
+
) : (
|
|
776
|
+
<RotateCw className="w-3.5 h-3.5" />
|
|
777
|
+
)}
|
|
778
|
+
</button>
|
|
779
|
+
</Tooltip>
|
|
780
|
+
)}
|
|
719
781
|
<Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
720
782
|
<button
|
|
721
783
|
onClick={() => {
|
|
722
784
|
commitInteraction()
|
|
723
785
|
stopPortForward(session.id)
|
|
724
786
|
}}
|
|
725
|
-
disabled={stoppingIds.has(session.id)}
|
|
787
|
+
disabled={stoppingIds.has(session.id) || retryingIds.has(session.id)}
|
|
726
788
|
className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
|
|
727
789
|
>
|
|
728
790
|
<Trash2 className="w-3.5 h-3.5" />
|
|
@@ -882,7 +944,7 @@ export function useStartPortForward() {
|
|
|
882
944
|
localPort?: number
|
|
883
945
|
listenAddress?: string // "127.0.0.1" (default) or "0.0.0.0"
|
|
884
946
|
}) => {
|
|
885
|
-
const res = await
|
|
947
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
886
948
|
method: 'POST',
|
|
887
949
|
headers: { 'Content-Type': 'application/json' },
|
|
888
950
|
body: JSON.stringify(req),
|
|
@@ -316,7 +316,7 @@ function PanelLoading() {
|
|
|
316
316
|
return (
|
|
317
317
|
<div className="flex items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs">
|
|
318
318
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
319
|
-
Loading
|
|
319
|
+
Loading…
|
|
320
320
|
</div>
|
|
321
321
|
)
|
|
322
322
|
}
|
|
@@ -23,6 +23,7 @@ import { getSkeletonYaml } from '../../utils/skeleton-yaml'
|
|
|
23
23
|
interface ResourceCountsResponse {
|
|
24
24
|
counts: Record<string, number>
|
|
25
25
|
forbidden?: string[]
|
|
26
|
+
reasons?: Record<string, string>
|
|
26
27
|
unavailable?: string[]
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -284,6 +285,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
284
285
|
// Lightweight counts for sidebar (replaces 233 parallel queries)
|
|
285
286
|
resourceCounts={countsData?.counts}
|
|
286
287
|
resourceForbidden={countsData?.forbidden}
|
|
288
|
+
resourceReasons={countsData?.reasons}
|
|
287
289
|
resourceUnavailable={countsData?.unavailable}
|
|
288
290
|
selectedKindQuery={selectedKindQueryResult}
|
|
289
291
|
largeListGuard={largeListGuard}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { useState, useEffect, useRef } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { Shield, X, Loader2 } from 'lucide-react'
|
|
3
|
+
import { Shield, X, Loader2, Lock, ExternalLink } from 'lucide-react'
|
|
4
4
|
import { clsx } from 'clsx'
|
|
5
|
+
import { useQuery } from '@tanstack/react-query'
|
|
5
6
|
import {
|
|
6
7
|
rbacVerbBadgeClass,
|
|
7
8
|
rbacResourceBadgeClass,
|
|
8
9
|
rbacApiGroupBadgeClass,
|
|
9
10
|
rbacResourceNameBadgeClass,
|
|
10
11
|
rbacNonResourceUrlBadgeClass,
|
|
12
|
+
type RBACWhoamiResponse,
|
|
11
13
|
} from '@skyhook-io/k8s-ui'
|
|
12
14
|
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
13
15
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
14
|
-
import { useNamespaces, useAuthMe } from '../../api/client'
|
|
16
|
+
import { useNamespaces, useAuthMe, fetchJSON } from '../../api/client'
|
|
15
17
|
import { useRBACWhoami } from '../../api/rbac'
|
|
16
18
|
|
|
17
19
|
interface MyPermissionsDialogProps {
|
|
@@ -71,7 +73,7 @@ export function MyPermissionsDialog({ open, onClose }: MyPermissionsDialogProps)
|
|
|
71
73
|
<div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
|
|
72
74
|
<div className="flex items-center gap-2">
|
|
73
75
|
<Shield className="w-5 h-5 text-theme-text-secondary" />
|
|
74
|
-
<h2 className="text-lg font-semibold text-theme-text-primary">
|
|
76
|
+
<h2 className="text-lg font-semibold text-theme-text-primary">Your access on this cluster</h2>
|
|
75
77
|
</div>
|
|
76
78
|
<button
|
|
77
79
|
onClick={onClose}
|
|
@@ -134,6 +136,8 @@ export function MyPermissionsDialog({ open, onClose }: MyPermissionsDialogProps)
|
|
|
134
136
|
) : whoami ? (
|
|
135
137
|
<PermissionsTable whoami={whoami} />
|
|
136
138
|
) : null}
|
|
139
|
+
|
|
140
|
+
<RestrictedResources enabled={open} />
|
|
137
141
|
</div>
|
|
138
142
|
</div>
|
|
139
143
|
</div>,
|
|
@@ -141,7 +145,7 @@ export function MyPermissionsDialog({ open, onClose }: MyPermissionsDialogProps)
|
|
|
141
145
|
)
|
|
142
146
|
}
|
|
143
147
|
|
|
144
|
-
function PermissionsTable({ whoami }: { whoami:
|
|
148
|
+
function PermissionsTable({ whoami }: { whoami: RBACWhoamiResponse }) {
|
|
145
149
|
const resourceRules = whoami.resourceRules ?? []
|
|
146
150
|
const nonResourceRules = whoami.nonResourceRules ?? []
|
|
147
151
|
|
|
@@ -229,3 +233,59 @@ function ResourceRuleRow({ rule }: { rule: { verbs?: string[]; apiGroups?: strin
|
|
|
229
233
|
</div>
|
|
230
234
|
)
|
|
231
235
|
}
|
|
236
|
+
|
|
237
|
+
// displayKind strips the API group from a resource-counts key ("group/Kind" →
|
|
238
|
+
// "Kind"; core kinds have no prefix).
|
|
239
|
+
function displayKind(countKey: string): string {
|
|
240
|
+
const i = countKey.indexOf('/')
|
|
241
|
+
return i === -1 ? countKey : countKey.slice(i + 1)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// RestrictedResources surfaces the kinds Radar isn't showing the user (the
|
|
245
|
+
// resource-counts `forbidden` set) — the "what's hidden from me" half of access,
|
|
246
|
+
// alongside the SelfSubjectRulesReview rules above. That set mixes RBAC denials
|
|
247
|
+
// with not-installed/not-watched kinds, so the copy says "usually RBAC" rather
|
|
248
|
+
// than asserting a cause, and links to the docs that carry the unblock RBAC.
|
|
249
|
+
function RestrictedResources({ enabled }: { enabled: boolean }) {
|
|
250
|
+
const { data } = useQuery<{ forbidden?: string[] }>({
|
|
251
|
+
queryKey: ['resource-counts', 'your-access'],
|
|
252
|
+
queryFn: () => fetchJSON('/resource-counts'),
|
|
253
|
+
enabled,
|
|
254
|
+
staleTime: 10000,
|
|
255
|
+
})
|
|
256
|
+
const forbidden = data?.forbidden ?? []
|
|
257
|
+
if (forbidden.length === 0) return null
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div>
|
|
261
|
+
<div className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
|
262
|
+
<Lock className="w-3.5 h-3.5 text-amber-400" />
|
|
263
|
+
Restricted or unavailable ({forbidden.length})
|
|
264
|
+
</div>
|
|
265
|
+
<p className="text-xs text-theme-text-tertiary mb-2">
|
|
266
|
+
Resource types Radar isn't showing you — usually because your RBAC doesn't allow listing
|
|
267
|
+
them, sometimes because the type isn't installed or watched on this cluster. Either way,
|
|
268
|
+
not an empty cluster.
|
|
269
|
+
</p>
|
|
270
|
+
<div className="flex flex-wrap gap-1.5">
|
|
271
|
+
{forbidden.map((k) => (
|
|
272
|
+
<span
|
|
273
|
+
key={k}
|
|
274
|
+
className="inline-flex items-center px-2 py-0.5 text-xs rounded border border-theme-border bg-theme-elevated text-theme-text-secondary"
|
|
275
|
+
>
|
|
276
|
+
{displayKind(k)}
|
|
277
|
+
</span>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
<a
|
|
281
|
+
href="https://radarhq.io/docs/cloud/rbac"
|
|
282
|
+
target="_blank"
|
|
283
|
+
rel="noreferrer"
|
|
284
|
+
className="inline-flex items-center gap-1 text-xs text-accent-text hover:underline mt-2"
|
|
285
|
+
>
|
|
286
|
+
How to get access
|
|
287
|
+
<ExternalLink className="w-3 h-3" />
|
|
288
|
+
</a>
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
@@ -39,7 +39,7 @@ export function LargeClusterNamespacePicker({ namespaces, onSelect }: {
|
|
|
39
39
|
<div className="max-h-[240px] overflow-y-auto rounded-lg border border-theme-border bg-theme-base">
|
|
40
40
|
{!namespaces ? (
|
|
41
41
|
<div className="px-3 py-6 text-center text-sm text-theme-text-tertiary">
|
|
42
|
-
Loading namespaces
|
|
42
|
+
Loading namespaces…
|
|
43
43
|
</div>
|
|
44
44
|
) : filtered.length === 0 ? (
|
|
45
45
|
<div className="px-3 py-6 text-center text-sm text-theme-text-tertiary">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useEffect, useState, useCallback, useRef } from 'react'
|
|
1
|
+
import { useMemo, useEffect, useState, useCallback, useRef, type MutableRefObject } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
ReactFlow,
|
|
4
4
|
Background,
|
|
@@ -971,6 +971,33 @@ const nodeTypes = {
|
|
|
971
971
|
addonGroup: AddonGroupNode,
|
|
972
972
|
}
|
|
973
973
|
|
|
974
|
+
// Fits the view once nodes have been laid out. Module-scope (not defined inside
|
|
975
|
+
// TrafficGraph's render) so it keeps a stable identity — otherwise it remounts
|
|
976
|
+
// every render and its effect churns. Must render inside <ReactFlow> for the
|
|
977
|
+
// useReactFlow() context; the trigger state is passed in as props.
|
|
978
|
+
function FitViewOnChange({
|
|
979
|
+
shouldFitViewRef,
|
|
980
|
+
layoutedNodes,
|
|
981
|
+
}: {
|
|
982
|
+
shouldFitViewRef: MutableRefObject<boolean>
|
|
983
|
+
layoutedNodes: Node<TrafficNodeData>[]
|
|
984
|
+
}) {
|
|
985
|
+
const { fitView } = useReactFlow()
|
|
986
|
+
|
|
987
|
+
useEffect(() => {
|
|
988
|
+
if (shouldFitViewRef.current && layoutedNodes.length > 0) {
|
|
989
|
+
// Small delay to ensure nodes are rendered
|
|
990
|
+
const timer = setTimeout(() => {
|
|
991
|
+
fitView({ padding: 0.2, duration: 200 })
|
|
992
|
+
shouldFitViewRef.current = false
|
|
993
|
+
}, 50)
|
|
994
|
+
return () => clearTimeout(timer)
|
|
995
|
+
}
|
|
996
|
+
}, [fitView, layoutedNodes, shouldFitViewRef])
|
|
997
|
+
|
|
998
|
+
return null
|
|
999
|
+
}
|
|
1000
|
+
|
|
974
1001
|
export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups = false, serviceCategories, addonMode = 'show', trafficSource = '', onSelectionChange }: TrafficGraphProps) {
|
|
975
1002
|
const isIstio = trafficSource === 'istio'
|
|
976
1003
|
const connLabel = isIstio ? 'req/s' : 'conn'
|
|
@@ -1492,24 +1519,6 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1492
1519
|
onSelectionChange?.(null)
|
|
1493
1520
|
}, [onSelectionChange])
|
|
1494
1521
|
|
|
1495
|
-
// FitView handler component - must be inside ReactFlow
|
|
1496
|
-
const FitViewOnChange = () => {
|
|
1497
|
-
const { fitView } = useReactFlow()
|
|
1498
|
-
|
|
1499
|
-
useEffect(() => {
|
|
1500
|
-
if (shouldFitViewRef.current && layoutedNodes.length > 0) {
|
|
1501
|
-
// Small delay to ensure nodes are rendered
|
|
1502
|
-
const timer = setTimeout(() => {
|
|
1503
|
-
fitView({ padding: 0.2, duration: 200 })
|
|
1504
|
-
shouldFitViewRef.current = false
|
|
1505
|
-
}, 50)
|
|
1506
|
-
return () => clearTimeout(timer)
|
|
1507
|
-
}
|
|
1508
|
-
}, [fitView, layoutedNodes])
|
|
1509
|
-
|
|
1510
|
-
return null
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
1522
|
return (
|
|
1514
1523
|
<div className="w-full h-full relative">
|
|
1515
1524
|
<ReactFlow
|
|
@@ -1535,7 +1544,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1535
1544
|
>
|
|
1536
1545
|
<Background />
|
|
1537
1546
|
<Controls />
|
|
1538
|
-
<FitViewOnChange />
|
|
1547
|
+
<FitViewOnChange shouldFitViewRef={shouldFitViewRef} layoutedNodes={layoutedNodes} />
|
|
1539
1548
|
</ReactFlow>
|
|
1540
1549
|
|
|
1541
1550
|
{/* Legend */}
|
|
@@ -620,13 +620,13 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
620
620
|
|
|
621
621
|
// Toggle L7 filter helpers
|
|
622
622
|
const toggleL7Method = useCallback((method: string) => {
|
|
623
|
-
setL7Methods(prev => { const next = new Set(prev); next.has(method)
|
|
623
|
+
setL7Methods(prev => { const next = new Set(prev); if (next.has(method)) next.delete(method); else next.add(method); return next })
|
|
624
624
|
}, [])
|
|
625
625
|
const toggleL7StatusRange = useCallback((range: string) => {
|
|
626
|
-
setL7StatusRanges(prev => { const next = new Set(prev); next.has(range)
|
|
626
|
+
setL7StatusRanges(prev => { const next = new Set(prev); if (next.has(range)) next.delete(range); else next.add(range); return next })
|
|
627
627
|
}, [])
|
|
628
628
|
const toggleL7Verdict = useCallback((verdict: string) => {
|
|
629
|
-
setL7Verdicts(prev => { const next = new Set(prev); next.has(verdict)
|
|
629
|
+
setL7Verdicts(prev => { const next = new Set(prev); if (next.has(verdict)) next.delete(verdict); else next.add(verdict); return next })
|
|
630
630
|
}, [])
|
|
631
631
|
|
|
632
632
|
// Toggle namespace visibility
|
|
@@ -99,7 +99,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
|
|
|
99
99
|
{/* Content */}
|
|
100
100
|
<div className="overflow-y-auto flex-1 px-5 py-4 space-y-4">
|
|
101
101
|
{isLoading && (
|
|
102
|
-
<div className="text-sm text-theme-text-tertiary text-center py-8">Loading diagnostics
|
|
102
|
+
<div className="text-sm text-theme-text-tertiary text-center py-8">Loading diagnostics…</div>
|
|
103
103
|
)}
|
|
104
104
|
{error && (
|
|
105
105
|
<div className="text-sm text-red-400 text-center py-8">Failed to load diagnostics: {(error as Error).message}</div>
|