@skyhook-io/radar-app 1.8.2 → 1.8.5
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 +5 -5
- package/src/App.tsx +412 -146
- package/src/RadarApp.tsx +21 -1
- package/src/api/client.ts +144 -12
- package/src/components/ConnectionErrorView.tsx +1 -1
- package/src/components/ContextSwitcher.tsx +5 -1
- package/src/components/NamespaceSwitcher.tsx +21 -278
- package/src/components/applications/ApplicationsView.tsx +13 -1
- package/src/components/audit/AuditView.tsx +11 -2
- package/src/components/cost/CostView.tsx +12 -2
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/gitops/GitOpsView.tsx +23 -17
- package/src/components/helm/HelmCompareRoute.tsx +1342 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +448 -67
- package/src/components/helm/HelmView.tsx +79 -62
- package/src/components/helm/ManifestDiffViewer.tsx +18 -7
- package/src/components/helm/OwnedResources.tsx +14 -50
- package/src/components/helm/RevisionHistory.tsx +9 -5
- package/src/components/helm/ValuesViewer.tsx +41 -11
- package/src/components/home/ClusterHealthCard.tsx +6 -1
- package/src/components/home/HomeView.tsx +12 -1
- package/src/components/home/mcpToolCatalog.ts +8 -8
- package/src/components/issues/IssuesPane.tsx +29 -18
- package/src/components/portforward/PortForwardButton.tsx +69 -25
- package/src/components/portforward/PortForwardManager.tsx +18 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
- package/src/components/resources/ResourcesView.tsx +45 -1
- package/src/components/resources/renderers/PodRenderer.tsx +7 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
- package/src/components/timeline/TimelineView.tsx +26 -2
- package/src/components/traffic/TrafficView.tsx +17 -10
- package/src/components/ui/Markdown.tsx +2 -2
- package/src/components/ui/Omnibar.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +5 -10
- package/src/components/workload/WorkloadView.tsx +57 -8
- package/src/contexts/CapabilitiesContext.tsx +8 -0
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/index.ts +15 -0
- package/src/main.tsx +5 -3
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +5 -3
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ApplicationDetail,
|
|
6
6
|
CenteredEmpty,
|
|
7
7
|
PageHeader,
|
|
8
|
+
FreshnessControl,
|
|
8
9
|
useToast,
|
|
9
10
|
orderEnvs,
|
|
10
11
|
matchWorkloadAcrossInstances,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
} from '@skyhook-io/k8s-ui'
|
|
19
20
|
import { Boxes } from 'lucide-react'
|
|
20
21
|
import { useApplications, useTopology } from '../../api/client'
|
|
22
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
21
23
|
import { kindToPlural } from '../../utils/navigation'
|
|
22
24
|
import { WorkloadView } from '../workload/WorkloadView'
|
|
23
25
|
|
|
@@ -28,8 +30,18 @@ interface ApplicationsViewProps {
|
|
|
28
30
|
|
|
29
31
|
export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsViewProps) {
|
|
30
32
|
const query = useApplications(namespaces)
|
|
33
|
+
const { connection } = useConnection()
|
|
31
34
|
const apps = useMemo(() => query.data?.applications ?? [], [query.data])
|
|
32
35
|
|
|
36
|
+
const freshness = (
|
|
37
|
+
<FreshnessControl
|
|
38
|
+
mode="auto"
|
|
39
|
+
dataUpdatedAt={query.dataUpdatedAt}
|
|
40
|
+
onRefresh={() => query.refetch()}
|
|
41
|
+
connectionState={connection.state}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
|
|
33
45
|
// Which app is open lives in the URL (?app=<key>) so the detail view is
|
|
34
46
|
// deep-linkable and the browser back button returns to the list. Opening or
|
|
35
47
|
// closing an app also clears the per-app params (workload, tab).
|
|
@@ -92,7 +104,7 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
92
104
|
|
|
93
105
|
return (
|
|
94
106
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
95
|
-
<ApplicationsList apps={apps} onSelect={selectApp} />
|
|
107
|
+
<ApplicationsList apps={apps} onSelect={selectApp} headerActions={freshness} />
|
|
96
108
|
</div>
|
|
97
109
|
)
|
|
98
110
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useState, useCallback } from 'react'
|
|
2
2
|
import { useAudit, useAuditSettings, useUpdateAuditSettings, useCloudRole } from '../../api/client'
|
|
3
3
|
import type { SelectedResource } from '../../types'
|
|
4
|
-
import { ChecksView, PaneLoader, PageHeader, type CheckResourceRef } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { ChecksView, PaneLoader, PageHeader, FreshnessControl, type CheckResourceRef } from '@skyhook-io/k8s-ui'
|
|
5
5
|
import { ShieldCheck, Settings } from 'lucide-react'
|
|
6
6
|
import { AuditSettingsDialog } from './AuditSettingsDialog'
|
|
7
7
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
8
9
|
|
|
9
10
|
interface AuditViewProps {
|
|
10
11
|
namespaces: string[]
|
|
@@ -18,7 +19,7 @@ interface AuditViewProps {
|
|
|
18
19
|
// ~/.radar settings are this cluster's "policy" and the row hide-menu writes to
|
|
19
20
|
// them.
|
|
20
21
|
export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps) {
|
|
21
|
-
const { data, isLoading, error } = useAudit(namespaces)
|
|
22
|
+
const { data, isLoading, error, dataUpdatedAt, refetch } = useAudit(namespaces)
|
|
22
23
|
const { data: auditSettings } = useAuditSettings()
|
|
23
24
|
const updateSettings = useUpdateAuditSettings()
|
|
24
25
|
// Audit policy is owner-gated (enforced server-side). Withhold the inline
|
|
@@ -30,6 +31,8 @@ export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps)
|
|
|
30
31
|
|
|
31
32
|
const ignoredCount = auditSettings?.ignoredNamespaces?.length ?? 0
|
|
32
33
|
|
|
34
|
+
const { connection } = useConnection()
|
|
35
|
+
|
|
33
36
|
// Inline hide actions — persist to local settings immediately.
|
|
34
37
|
const hideCheck = useCallback((checkID: string) => {
|
|
35
38
|
if (!auditSettings) return
|
|
@@ -80,6 +83,12 @@ export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps)
|
|
|
80
83
|
description="Security, reliability, and efficiency best practices (NSA/CISA, CIS, Polaris, Kubescape), grouped into a remediation queue."
|
|
81
84
|
actions={
|
|
82
85
|
<>
|
|
86
|
+
<FreshnessControl
|
|
87
|
+
mode="auto"
|
|
88
|
+
dataUpdatedAt={dataUpdatedAt}
|
|
89
|
+
onRefresh={() => refetch()}
|
|
90
|
+
connectionState={connection.state}
|
|
91
|
+
/>
|
|
83
92
|
{ignoredCount > 0 && (
|
|
84
93
|
<button onClick={() => setShowSettings(true)} className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors">{ignoredCount} {ignoredCount === 1 ? 'namespace' : 'namespaces'} hidden</button>
|
|
85
94
|
)}
|
|
@@ -2,17 +2,19 @@ import { useState, useEffect } from 'react'
|
|
|
2
2
|
import { useOpenCostSummary, useOpenCostWorkloads, useOpenCostNodes } from '../../api/client'
|
|
3
3
|
import type { OpenCostNamespaceCost, OpenCostWorkloadCost, OpenCostNodeCost } from '../../api/client'
|
|
4
4
|
import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, HelpCircle, Loader2, Server, X } from 'lucide-react'
|
|
5
|
-
import { PaneLoader } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui'
|
|
6
6
|
import { CostTrendChart } from './CostTrendChart'
|
|
7
7
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
8
9
|
|
|
9
10
|
interface CostViewProps {
|
|
10
11
|
onBack: () => void
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function CostView({ onBack }: CostViewProps) {
|
|
14
|
-
const { data, isLoading } = useOpenCostSummary()
|
|
15
|
+
const { data, isLoading, dataUpdatedAt, refetch } = useOpenCostSummary()
|
|
15
16
|
const { data: nodeData } = useOpenCostNodes()
|
|
17
|
+
const { connection } = useConnection()
|
|
16
18
|
const [showHelp, setShowHelp] = useState(false)
|
|
17
19
|
|
|
18
20
|
if (isLoading) {
|
|
@@ -90,6 +92,14 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
90
92
|
</button>
|
|
91
93
|
</div>
|
|
92
94
|
<div className="flex items-center gap-4">
|
|
95
|
+
{/* Tracks the headline $/hr summary (the primary query); its load
|
|
96
|
+
time is the representative freshness signal for the view. */}
|
|
97
|
+
<FreshnessControl
|
|
98
|
+
mode="auto"
|
|
99
|
+
dataUpdatedAt={dataUpdatedAt}
|
|
100
|
+
onRefresh={() => refetch()}
|
|
101
|
+
connectionState={connection.state}
|
|
102
|
+
/>
|
|
93
103
|
{hasEfficiency && (
|
|
94
104
|
<div className="flex flex-col items-end gap-0.5">
|
|
95
105
|
<div className="flex items-center gap-2 text-sm">
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { useState, useEffect, useLayoutEffect, useRef } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { useMutation } from '@tanstack/react-query'
|
|
4
|
+
import { Activity, Loader2, X, ChevronDown, Maximize2, Copy, Check } from 'lucide-react'
|
|
5
|
+
import { clsx } from 'clsx'
|
|
6
|
+
import { apiFetch } from '../../api/client'
|
|
7
|
+
import { apiUrl } from '../../api/config'
|
|
8
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
9
|
+
|
|
10
|
+
// A port is "curl-able" only if it plausibly speaks HTTP — probing a raw TCP
|
|
11
|
+
// port (Postgres, Redis) with a GET returns noise, so we don't offer it there
|
|
12
|
+
// (that's the local-client TCP path's job). Heuristic over name/appProtocol/number.
|
|
13
|
+
const HTTP_PORT_NUMBERS = new Set([80, 443, 8080, 8443, 8000, 8081, 3000, 5000, 9090, 9091, 9093, 9100, 15000, 15090])
|
|
14
|
+
const HTTP_NAME_RE = /(^|[-_])(http|https|web|ui|console|dashboard|metrics|api|admin)([-_]|$)/i
|
|
15
|
+
|
|
16
|
+
// Common metrics port numbers — used to decide which quick-path chips make sense.
|
|
17
|
+
const METRICS_PORT_NUMBERS = new Set([9090, 9091, 9093, 9100, 9153, 2112, 8888])
|
|
18
|
+
|
|
19
|
+
function isMetricsPort(port: number, name?: string, appProtocol?: string): boolean {
|
|
20
|
+
if ((appProtocol || '').toLowerCase().includes('metric')) return true
|
|
21
|
+
if (name && /metric/i.test(name)) return true
|
|
22
|
+
return METRICS_PORT_NUMBERS.has(port)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Default request path for a port. Only metrics ports get a non-root default —
|
|
26
|
+
// /metrics is a near-deterministic convention there. We deliberately don't
|
|
27
|
+
// pre-fill or suggest health paths (/healthz etc.): those are genuine guesses
|
|
28
|
+
// (apps vary: /health, /actuator/health, /-/healthy …) and a suggestion that
|
|
29
|
+
// 404s reads as the tool being wrong. The honest one-click-health feature is to
|
|
30
|
+
// derive paths from the backing pod's liveness/readiness probes — a follow-up.
|
|
31
|
+
export function defaultPathForPort(port: number, name?: string, appProtocol?: string): string {
|
|
32
|
+
return isMetricsPort(port, name, appProtocol) ? '/metrics' : '/'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isHttpishPort(port: number, name?: string, appProtocol?: string, protocol?: string): boolean {
|
|
36
|
+
// HTTP rides TCP — a UDP port is never a GET target (e.g. statsd "metrics-udp").
|
|
37
|
+
if ((protocol || '').toUpperCase() === 'UDP') return false
|
|
38
|
+
const proto = (appProtocol || '').toLowerCase()
|
|
39
|
+
if (proto === 'http' || proto === 'https' || proto === 'http2') return true
|
|
40
|
+
if (proto && proto !== 'tcp') {
|
|
41
|
+
// explicit non-HTTP appProtocol (grpc, redis, postgres, …) → not a GET target
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
if (name && HTTP_NAME_RE.test(name)) return true
|
|
45
|
+
return HTTP_PORT_NUMBERS.has(port)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function defaultScheme(port: number, name?: string, appProtocol?: string): 'http' | 'https' {
|
|
49
|
+
if ((appProtocol || '').toLowerCase() === 'https') return 'https'
|
|
50
|
+
if (port === 443 || port === 8443) return 'https'
|
|
51
|
+
if (name && /https/i.test(name)) return 'https'
|
|
52
|
+
return 'http'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface CurlResult {
|
|
56
|
+
status: number
|
|
57
|
+
statusText: string
|
|
58
|
+
durationMs: number
|
|
59
|
+
headers: Record<string, string>
|
|
60
|
+
body: string
|
|
61
|
+
truncated: boolean
|
|
62
|
+
bodyBytes: number
|
|
63
|
+
error?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function statusTextTone(status: number): string {
|
|
67
|
+
if (status >= 200 && status < 300) return 'text-emerald-400'
|
|
68
|
+
if (status >= 300 && status < 400) return 'text-blue-400'
|
|
69
|
+
if (status >= 400 && status < 500) return 'text-amber-400'
|
|
70
|
+
return 'text-red-400'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function statusDotTone(status: number): string {
|
|
74
|
+
if (status >= 200 && status < 300) return 'bg-emerald-400'
|
|
75
|
+
if (status >= 300 && status < 400) return 'bg-blue-400'
|
|
76
|
+
if (status >= 400 && status < 500) return 'bg-amber-400'
|
|
77
|
+
return 'bg-red-400'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Make the body readable per content type: pretty-print JSON, label everything
|
|
81
|
+
// else (HTML / Prometheus / XML / …) so the operator knows what they're looking at.
|
|
82
|
+
function formatBody(result: CurlResult): { text: string; label: string } {
|
|
83
|
+
const body = result.body
|
|
84
|
+
const ct = (result.headers['Content-Type'] || result.headers['content-type'] || '').toLowerCase()
|
|
85
|
+
const looksJson = ct.includes('json') || /^\s*[[{]/.test(body)
|
|
86
|
+
if (looksJson) {
|
|
87
|
+
let text = body
|
|
88
|
+
if (!result.truncated) {
|
|
89
|
+
try { text = JSON.stringify(JSON.parse(body), null, 2) } catch { /* leave raw */ }
|
|
90
|
+
}
|
|
91
|
+
return { text, label: 'JSON' }
|
|
92
|
+
}
|
|
93
|
+
if (ct.includes('html')) return { text: body, label: 'HTML' }
|
|
94
|
+
if (body.startsWith('# HELP') || body.startsWith('# TYPE') || ct.includes('openmetrics')) {
|
|
95
|
+
return { text: body, label: 'Prometheus' }
|
|
96
|
+
}
|
|
97
|
+
if (ct.includes('xml')) return { text: body, label: 'XML' }
|
|
98
|
+
const short = ct ? (ct.split(';')[0].split('/').pop() || 'text') : 'text'
|
|
99
|
+
return { text: body, label: short }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function CopyButton({ text, className }: { text: string; className?: string }) {
|
|
103
|
+
const [copied, setCopied] = useState(false)
|
|
104
|
+
return (
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={(e) => {
|
|
108
|
+
e.stopPropagation()
|
|
109
|
+
navigator.clipboard?.writeText(text).then(() => {
|
|
110
|
+
setCopied(true)
|
|
111
|
+
setTimeout(() => setCopied(false), 1500)
|
|
112
|
+
}).catch(() => {})
|
|
113
|
+
}}
|
|
114
|
+
className={clsx('inline-flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary', className)}
|
|
115
|
+
>
|
|
116
|
+
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
|
117
|
+
{copied ? 'Copied' : 'Copy'}
|
|
118
|
+
</button>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Small toggle button rendered in a port row's action slot. The panel itself
|
|
123
|
+
// renders inline within the port card (see CurlPanel), not as an overlay.
|
|
124
|
+
export function CurlButton({ active, onClick }: { active: boolean; onClick: () => void }) {
|
|
125
|
+
return (
|
|
126
|
+
<Tooltip content="Curl this endpoint — GET from inside the cluster">
|
|
127
|
+
<button
|
|
128
|
+
onClick={(e) => { e.stopPropagation(); onClick() }}
|
|
129
|
+
aria-expanded={active}
|
|
130
|
+
className={clsx(
|
|
131
|
+
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors',
|
|
132
|
+
active ? 'bg-accent-muted text-blue-400' : 'bg-theme-elevated hover:bg-accent-muted',
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
Curl
|
|
136
|
+
{/* Disclosure caret: signals this expands an inline panel rather than firing a request. */}
|
|
137
|
+
<ChevronDown className={clsx('w-3 h-3 transition-transform', active && 'rotate-180')} />
|
|
138
|
+
</button>
|
|
139
|
+
</Tooltip>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function VerdictLine({
|
|
144
|
+
result,
|
|
145
|
+
showHeaders,
|
|
146
|
+
onToggleHeaders,
|
|
147
|
+
}: {
|
|
148
|
+
result: CurlResult
|
|
149
|
+
showHeaders: boolean
|
|
150
|
+
onToggleHeaders: () => void
|
|
151
|
+
}) {
|
|
152
|
+
return (
|
|
153
|
+
<div className="flex items-center gap-3 text-xs">
|
|
154
|
+
<span className={clsx('flex items-center gap-1.5 font-mono font-semibold', statusTextTone(result.status))}>
|
|
155
|
+
<span className={clsx('w-1.5 h-1.5 rounded-full', statusDotTone(result.status))} />
|
|
156
|
+
{result.status}{result.statusText ? ` ${result.statusText}` : ''}
|
|
157
|
+
</span>
|
|
158
|
+
<span className="text-theme-text-tertiary">{result.durationMs} ms</span>
|
|
159
|
+
<span className="text-theme-text-tertiary">{result.bodyBytes.toLocaleString()} bytes{result.truncated ? ' (truncated)' : ''}</span>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={onToggleHeaders}
|
|
163
|
+
className="ml-auto flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary"
|
|
164
|
+
>
|
|
165
|
+
Headers <ChevronDown className={clsx('w-3 h-3 transition-transform', showHeaders && 'rotate-180')} />
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Roomy response viewer. The narrow drawer can't show a 27 KB /metrics body
|
|
172
|
+
// readably (wide lines wrap into mush), so the full body opens in a centered
|
|
173
|
+
// dialog — wide, tall, monospace, no-wrap with its own scroll (decoupled from the
|
|
174
|
+
// drawer, so there's no nested-scroll). Triggered on demand; the request + verdict
|
|
175
|
+
// stay inline in the port card. Matches the kubectl copy-command dialog pattern.
|
|
176
|
+
function CurlResponseDialog({
|
|
177
|
+
serviceName,
|
|
178
|
+
port,
|
|
179
|
+
scheme,
|
|
180
|
+
path,
|
|
181
|
+
result,
|
|
182
|
+
onClose,
|
|
183
|
+
}: {
|
|
184
|
+
serviceName: string
|
|
185
|
+
port: number
|
|
186
|
+
scheme: string
|
|
187
|
+
path: string
|
|
188
|
+
result: CurlResult
|
|
189
|
+
onClose: () => void
|
|
190
|
+
}) {
|
|
191
|
+
const [showHeaders, setShowHeaders] = useState(false)
|
|
192
|
+
const { text, label } = formatBody(result)
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
// Capture + stopPropagation so Escape closes only this dialog, not the drawer
|
|
195
|
+
// behind it (its Escape shortcut listens in the bubble phase).
|
|
196
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); onClose() } }
|
|
197
|
+
document.addEventListener('keydown', onKey, true)
|
|
198
|
+
return () => document.removeEventListener('keydown', onKey, true)
|
|
199
|
+
}, [onClose])
|
|
200
|
+
// Portal to <body>: the drawer is a transformed ancestor, which would otherwise
|
|
201
|
+
// trap this position:fixed dialog inside the drawer instead of centering it on
|
|
202
|
+
// the viewport.
|
|
203
|
+
return createPortal(
|
|
204
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
|
205
|
+
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
206
|
+
<div className="relative dialog w-full max-w-4xl mx-4 max-h-[85vh] flex flex-col outline-none">
|
|
207
|
+
<div className="flex items-center justify-between gap-3 p-4 border-b border-theme-border">
|
|
208
|
+
<div className="min-w-0">
|
|
209
|
+
<div className="flex items-center gap-2">
|
|
210
|
+
<Activity className="w-4 h-4 text-blue-400 shrink-0" />
|
|
211
|
+
<h3 className="text-sm font-semibold text-theme-text-primary truncate">Response</h3>
|
|
212
|
+
</div>
|
|
213
|
+
<div className="text-xs text-theme-text-tertiary font-mono mt-0.5 truncate">
|
|
214
|
+
GET {scheme}://{serviceName}:{port}{path}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
<button onClick={onClose} aria-label="Close" className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0">
|
|
218
|
+
<X className="w-5 h-5" />
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<div className="px-4 py-2 border-b border-theme-border">
|
|
223
|
+
<VerdictLine result={result} showHeaders={showHeaders} onToggleHeaders={() => setShowHeaders((v) => !v)} />
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<div className="grid transition-[grid-template-rows] duration-200 ease-out mx-4" style={{ gridTemplateRows: showHeaders ? '1fr' : '0fr' }}>
|
|
227
|
+
<div className="overflow-hidden">
|
|
228
|
+
<pre className="text-xs bg-theme-base mt-4 rounded p-3 overflow-auto max-h-48 text-theme-text-secondary font-mono whitespace-pre">
|
|
229
|
+
{Object.entries(result.headers).map(([k, v]) => `${k}: ${v}`).join('\n') || '(no headers)'}
|
|
230
|
+
</pre>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{result.error ? (
|
|
235
|
+
<div className="m-4 text-sm text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2">
|
|
236
|
+
{result.error}
|
|
237
|
+
</div>
|
|
238
|
+
) : (
|
|
239
|
+
<div className="flex flex-col min-h-0 flex-1 m-4">
|
|
240
|
+
<div className="flex items-center justify-between mb-1.5">
|
|
241
|
+
<span className="badge-sm bg-theme-elevated text-theme-text-secondary border border-theme-border">{label}</span>
|
|
242
|
+
{result.body && <CopyButton text={text} />}
|
|
243
|
+
</div>
|
|
244
|
+
<pre className="flex-1 text-xs bg-theme-base rounded p-3 overflow-auto text-theme-text-primary font-mono whitespace-pre">
|
|
245
|
+
{text || '(empty response body)'}
|
|
246
|
+
</pre>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
</div>,
|
|
251
|
+
document.body,
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Inline curl: request form + verdict + a short body peek, rendered in the
|
|
256
|
+
// drawer flow (inside the port card). The full body opens in CurlResponseDialog
|
|
257
|
+
// so a large response never bloats the drawer.
|
|
258
|
+
export function CurlPanel({
|
|
259
|
+
namespace,
|
|
260
|
+
serviceName,
|
|
261
|
+
port,
|
|
262
|
+
initialScheme,
|
|
263
|
+
initialPath,
|
|
264
|
+
open,
|
|
265
|
+
onClose,
|
|
266
|
+
}: {
|
|
267
|
+
namespace: string
|
|
268
|
+
serviceName: string
|
|
269
|
+
port: number
|
|
270
|
+
initialScheme: 'http' | 'https'
|
|
271
|
+
initialPath: string
|
|
272
|
+
// Host-controlled: false triggers the collapse animation before the host unmounts.
|
|
273
|
+
open: boolean
|
|
274
|
+
onClose: () => void
|
|
275
|
+
}) {
|
|
276
|
+
const [scheme, setScheme] = useState<'http' | 'https'>(initialScheme)
|
|
277
|
+
// Stored WITHOUT the leading slash — the "/" is a fixed, non-deletable prefix
|
|
278
|
+
// glued to the input. Typed/pasted leading slashes are swallowed on change.
|
|
279
|
+
const [path, setPath] = useState(() => initialPath.replace(/^\/+/, ''))
|
|
280
|
+
const fullPath = '/' + path
|
|
281
|
+
const [showHeaders, setShowHeaders] = useState(false)
|
|
282
|
+
const [sheetOpen, setSheetOpen] = useState(false)
|
|
283
|
+
// What was actually sent — so the sheet header / re-renders reflect the response.
|
|
284
|
+
const [sent, setSent] = useState<{ scheme: 'http' | 'https'; path: string }>({ scheme: initialScheme, path: initialPath })
|
|
285
|
+
// Enter animation: mount collapsed, then expand next tick (radar's grid 0fr↔1fr).
|
|
286
|
+
// Combined with the host-controlled `open` prop this gives a symmetric reveal:
|
|
287
|
+
// expand on mount, collapse when the host sets open=false (before it unmounts).
|
|
288
|
+
const [mounted, setMounted] = useState(false)
|
|
289
|
+
useEffect(() => { setMounted(true) }, [])
|
|
290
|
+
|
|
291
|
+
const curl = useMutation<CurlResult, Error, { scheme: 'http' | 'https'; path: string }>({
|
|
292
|
+
mutationFn: async (vars) => {
|
|
293
|
+
setSent(vars)
|
|
294
|
+
const res = await apiFetch(apiUrl('/curl/service'), {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: { 'Content-Type': 'application/json' },
|
|
297
|
+
body: JSON.stringify({ namespace, name: serviceName, port: String(port), scheme: vars.scheme, path: vars.path }),
|
|
298
|
+
})
|
|
299
|
+
const data = await res.json().catch(() => ({}))
|
|
300
|
+
if (!res.ok) throw new Error(data?.error || `Request failed (${res.status})`)
|
|
301
|
+
return data as CurlResult
|
|
302
|
+
},
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const result = curl.data
|
|
306
|
+
const peek = result && !result.error ? formatBody(result) : null
|
|
307
|
+
|
|
308
|
+
// Only fade + offer "View full response" when the body actually overflows the
|
|
309
|
+
// peek box — a small body that fits has nothing more to show, and a fade over
|
|
310
|
+
// it reads as a rendering glitch.
|
|
311
|
+
const peekRef = useRef<HTMLPreElement>(null)
|
|
312
|
+
const [peekOverflows, setPeekOverflows] = useState(false)
|
|
313
|
+
useLayoutEffect(() => {
|
|
314
|
+
const el = peekRef.current
|
|
315
|
+
setPeekOverflows(!!el && el.scrollHeight > el.clientHeight + 1)
|
|
316
|
+
}, [peek?.text, open])
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: mounted && open ? '1fr' : '0fr' }}>
|
|
320
|
+
<div className="overflow-hidden">
|
|
321
|
+
<div className="mt-3 pt-3 border-t border-theme-border space-y-2" onClick={(e) => e.stopPropagation()}>
|
|
322
|
+
<div className="flex items-center justify-between">
|
|
323
|
+
<span className="flex items-center gap-1.5 text-xs font-medium text-theme-text-secondary">
|
|
324
|
+
<Activity className="w-3.5 h-3.5 text-blue-400" />
|
|
325
|
+
Curl — GET from inside the cluster
|
|
326
|
+
</span>
|
|
327
|
+
<button
|
|
328
|
+
onClick={onClose}
|
|
329
|
+
aria-label="Close"
|
|
330
|
+
className="p-0.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
331
|
+
>
|
|
332
|
+
<X className="w-3.5 h-3.5" />
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<form className="flex items-stretch gap-2" onSubmit={(e) => { e.preventDefault(); curl.mutate({ scheme, path: fullPath }) }}>
|
|
337
|
+
<select
|
|
338
|
+
value={scheme}
|
|
339
|
+
onChange={(e) => setScheme(e.target.value as 'http' | 'https')}
|
|
340
|
+
className="bg-theme-base border border-theme-border rounded px-2 py-1 text-xs text-theme-text-primary font-mono"
|
|
341
|
+
aria-label="Scheme"
|
|
342
|
+
>
|
|
343
|
+
<option value="http">http</option>
|
|
344
|
+
<option value="https">https</option>
|
|
345
|
+
</select>
|
|
346
|
+
<div className="flex-1 min-w-0 flex items-center bg-theme-base border border-theme-border rounded px-2 focus-within:border-blue-500">
|
|
347
|
+
<span className="text-xs text-theme-text-tertiary font-mono select-none pointer-events-none">/</span>
|
|
348
|
+
<input
|
|
349
|
+
type="text"
|
|
350
|
+
value={path}
|
|
351
|
+
onChange={(e) => setPath(e.target.value.replace(/^\/+/, ''))}
|
|
352
|
+
placeholder="healthz"
|
|
353
|
+
aria-label="Request path"
|
|
354
|
+
className="flex-1 min-w-0 bg-transparent border-0 outline-none pl-0.5 py-1 text-xs text-theme-text-primary font-mono"
|
|
355
|
+
/>
|
|
356
|
+
</div>
|
|
357
|
+
<button
|
|
358
|
+
type="submit"
|
|
359
|
+
disabled={curl.isPending}
|
|
360
|
+
className="shrink-0 px-3 py-1 btn-brand text-xs rounded-lg flex items-center gap-1.5 disabled:opacity-50"
|
|
361
|
+
>
|
|
362
|
+
{curl.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Activity className="w-3.5 h-3.5" />}
|
|
363
|
+
Send
|
|
364
|
+
</button>
|
|
365
|
+
</form>
|
|
366
|
+
|
|
367
|
+
{curl.isError && (
|
|
368
|
+
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded px-2 py-1.5">
|
|
369
|
+
{(curl.error as Error).message}
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{/* Reveal the response with the same grid transition as the panel itself. */}
|
|
374
|
+
<div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: result ? '1fr' : '0fr' }}>
|
|
375
|
+
<div className="overflow-hidden">
|
|
376
|
+
{result && (
|
|
377
|
+
<div className="space-y-2 pt-0.5">
|
|
378
|
+
<VerdictLine result={result} showHeaders={showHeaders} onToggleHeaders={() => setShowHeaders((v) => !v)} />
|
|
379
|
+
|
|
380
|
+
{result.error && (
|
|
381
|
+
<div className="text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-2 py-1.5">
|
|
382
|
+
{result.error}
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
|
|
386
|
+
<div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: showHeaders ? '1fr' : '0fr' }}>
|
|
387
|
+
<div className="overflow-hidden">
|
|
388
|
+
<pre className="text-xs bg-theme-base rounded p-2 overflow-auto max-h-32 text-theme-text-secondary font-mono whitespace-pre">
|
|
389
|
+
{Object.entries(result.headers).map(([k, v]) => `${k}: ${v}`).join('\n') || '(no headers)'}
|
|
390
|
+
</pre>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
{peek && (
|
|
395
|
+
<>
|
|
396
|
+
{result.body && (
|
|
397
|
+
<div className="flex items-center justify-between">
|
|
398
|
+
<span className="badge-sm bg-theme-elevated text-theme-text-secondary border border-theme-border">{peek.label}</span>
|
|
399
|
+
<CopyButton text={peek.text} />
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
{/* Short peek — a bounded teaser, not a scroll surface (the full body
|
|
403
|
+
has its own scrollable dialog). When the body overflows, a bottom
|
|
404
|
+
fade signals "more below"; when it fits, no fade and no "view full"
|
|
405
|
+
(there's nothing more to see). */}
|
|
406
|
+
<div className="relative">
|
|
407
|
+
<pre ref={peekRef} className="text-xs bg-theme-base rounded p-2 overflow-hidden max-h-24 text-theme-text-primary font-mono whitespace-pre-wrap break-words">
|
|
408
|
+
{peek.text || '(empty response body)'}
|
|
409
|
+
</pre>
|
|
410
|
+
{result.body && peekOverflows && (
|
|
411
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 rounded-b bg-gradient-to-t from-theme-base to-transparent" />
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
{result.body && peekOverflows && (
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
onClick={() => setSheetOpen(true)}
|
|
418
|
+
className="flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300"
|
|
419
|
+
>
|
|
420
|
+
<Maximize2 className="w-3 h-3" />
|
|
421
|
+
View full response
|
|
422
|
+
</button>
|
|
423
|
+
)}
|
|
424
|
+
</>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
{sheetOpen && result && (
|
|
432
|
+
<CurlResponseDialog
|
|
433
|
+
serviceName={serviceName}
|
|
434
|
+
port={port}
|
|
435
|
+
scheme={sent.scheme}
|
|
436
|
+
path={sent.path}
|
|
437
|
+
result={result}
|
|
438
|
+
onClose={() => setSheetOpen(false)}
|
|
439
|
+
/>
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
)
|
|
445
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
GitOpsDetailLayout,
|
|
9
9
|
GitOpsGraphFilterRail,
|
|
10
10
|
GitOpsTableView as SharedGitOpsTableView,
|
|
11
|
+
FreshnessControl,
|
|
11
12
|
GitOpsTreeGraph,
|
|
12
13
|
RollbackDialog,
|
|
13
14
|
SyncOptionsDialog,
|
|
@@ -61,6 +62,7 @@ import {
|
|
|
61
62
|
useResource,
|
|
62
63
|
} from '../../api/client'
|
|
63
64
|
import { useAPIResources } from '../../api/apiResources'
|
|
65
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
64
66
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
65
67
|
import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
|
|
66
68
|
import { CodeViewer } from '../ui/CodeViewer'
|
|
@@ -80,6 +82,11 @@ const GITOPS_KINDS: APIResource[] = [
|
|
|
80
82
|
|
|
81
83
|
const KIND_BY_NAME = new Map(GITOPS_KINDS.map((k) => [k.name, k]))
|
|
82
84
|
|
|
85
|
+
// Rows are the table's primary content; their poll cadence is what the toolbar
|
|
86
|
+
// freshness signal advertises ("Auto-refreshes every 2m"). Single source of
|
|
87
|
+
// truth so the signal can't drift from the actual refetchInterval below.
|
|
88
|
+
const GITOPS_ROWS_REFRESH_INTERVAL_MS = 120_000
|
|
89
|
+
|
|
83
90
|
interface ResourceCountsResponse {
|
|
84
91
|
counts: Record<string, number>
|
|
85
92
|
forbidden?: string[]
|
|
@@ -102,6 +109,7 @@ export function GitOpsView({ namespaces, onOpenResource, onClearNamespaces }: Gi
|
|
|
102
109
|
|
|
103
110
|
function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string[]; onClearNamespaces?: () => void }) {
|
|
104
111
|
const navigate = useNavigate()
|
|
112
|
+
const { connection } = useConnection()
|
|
105
113
|
const namespacesParam = namespaces.join(',')
|
|
106
114
|
const { data: apiResources, isLoading: apiResourcesLoading } = useAPIResources()
|
|
107
115
|
|
|
@@ -191,7 +199,7 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
191
199
|
},
|
|
192
200
|
enabled: !apiResourcesLoading,
|
|
193
201
|
staleTime: 30_000,
|
|
194
|
-
refetchInterval:
|
|
202
|
+
refetchInterval: GITOPS_ROWS_REFRESH_INTERVAL_MS,
|
|
195
203
|
})
|
|
196
204
|
|
|
197
205
|
// Row mutations invalidate granular keys (['resource', …], ['gitops-tree', …])
|
|
@@ -201,11 +209,11 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
201
209
|
// inviting a duplicate request. Radar serves reads from an informer cache that
|
|
202
210
|
// lags the write by the watch-propagation delay, so refetch once now (covers
|
|
203
211
|
// an already-current cache) and once shortly after to catch the propagated
|
|
204
|
-
// update; refetch() forces a fetch regardless of staleTime.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
212
|
+
// update; refetch() forces a fetch regardless of staleTime. The toolbar's
|
|
213
|
+
// manual refresh reuses refetchTable so rows + counts stay in sync.
|
|
214
|
+
// Return the combined promise so the toolbar's refresh animation waits for the
|
|
215
|
+
// real fetches to settle before showing its success checkmark.
|
|
216
|
+
const refetchTable = () => Promise.all([rowsQuery.refetch(), countsQuery.refetch()])
|
|
209
217
|
const refetchTableAfterMutation = () => {
|
|
210
218
|
refetchTable()
|
|
211
219
|
window.setTimeout(refetchTable, 1200)
|
|
@@ -280,7 +288,14 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
|
|
|
280
288
|
error={(rowsQuery.error as Error | null) ?? null}
|
|
281
289
|
counts={countsQuery.data?.counts ?? {}}
|
|
282
290
|
countsUnavailable={countsQuery.data?.unavailable}
|
|
283
|
-
|
|
291
|
+
freshnessSlot={
|
|
292
|
+
<FreshnessControl
|
|
293
|
+
mode="auto"
|
|
294
|
+
dataUpdatedAt={rowsQuery.dataUpdatedAt}
|
|
295
|
+
onRefresh={refetchTable}
|
|
296
|
+
connectionState={connection.state}
|
|
297
|
+
/>
|
|
298
|
+
}
|
|
284
299
|
onRowClick={(row) => {
|
|
285
300
|
const ns = row.namespace || '_'
|
|
286
301
|
const params = new URLSearchParams()
|
|
@@ -446,15 +461,6 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
446
461
|
const isFlux = tool === 'flux'
|
|
447
462
|
const isArgoApp = kind === 'applications'
|
|
448
463
|
|
|
449
|
-
// Set the browser tab title so users with multiple resource tabs open can
|
|
450
|
-
// tell which is which without focusing each tab. Restore on unmount so a
|
|
451
|
-
// stray "Radar — argocd/foo" doesn't outlive its page.
|
|
452
|
-
useEffect(() => {
|
|
453
|
-
const previous = document.title
|
|
454
|
-
document.title = `${name} — Radar`
|
|
455
|
-
return () => { document.title = previous }
|
|
456
|
-
}, [name])
|
|
457
|
-
|
|
458
464
|
// Detail-page shortcuts. Skip when a modal is already open so a stray "s"
|
|
459
465
|
// in an input field doesn't pop another sync dialog.
|
|
460
466
|
const shortcutsEnabled = !syncDialogOpen && !rollbackTarget
|
|
@@ -645,7 +651,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
|
|
|
645
651
|
search: params.toString(),
|
|
646
652
|
})
|
|
647
653
|
} : undefined}
|
|
648
|
-
manageDocumentTitle={false /*
|
|
654
|
+
manageDocumentTitle={false /* title handled centrally in App's radarPageTitle */}
|
|
649
655
|
renderTabBarCounts={({ tab }) => (
|
|
650
656
|
tab === 'topology' && tree ? <TopologyCounts tree={tree} /> : null
|
|
651
657
|
)}
|