@skyhook-io/radar-app 0.2.2 → 1.0.1
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 +143 -36
- package/src/api/client.ts +121 -4
- package/src/components/ContextSwitcher.tsx +49 -16
- package/src/components/NamespaceSwitcher.tsx +298 -0
- package/src/components/audit/AuditSettingsDialog.tsx +49 -10
- package/src/components/helm/ChartBrowser.tsx +11 -11
- package/src/components/helm/HelmReleaseDrawer.tsx +35 -19
- package/src/components/helm/HelmView.tsx +33 -7
- package/src/components/helm/InstallWizard.tsx +79 -22
- package/src/components/home/HomeView.tsx +13 -1
- package/src/components/portforward/PortForwardButton.tsx +37 -16
- package/src/components/portforward/PortForwardManager.tsx +152 -111
- package/src/components/resources/ResourcesView.tsx +2 -2
- package/src/components/timeline/TimelineSwimlanes.tsx +17 -18
- package/src/components/ui/DiagnosticsOverlay.tsx +93 -2
- package/src/components/ui/UpdateNotification.tsx +7 -7
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
3
|
import { PaneLoader } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
|
|
4
5
|
import { TRANSITION_DRAWER } from '../../utils/animation'
|
|
5
6
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
6
7
|
import { X, Copy, Check, RefreshCw, Package, Code, History, FileText, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2 } from 'lucide-react'
|
|
@@ -55,16 +56,17 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
55
56
|
// transient error state under the role-gated panel.
|
|
56
57
|
const { canAtLeast } = useCloudRole()
|
|
57
58
|
const canViewSensitive = canAtLeast('member')
|
|
59
|
+
const helmNamespace = release.storageNamespace || release.namespace
|
|
58
60
|
|
|
59
61
|
const { data: releaseDetail, isLoading, refetch: refetchRelease } = useHelmRelease(
|
|
60
|
-
|
|
62
|
+
helmNamespace,
|
|
61
63
|
release.name
|
|
62
64
|
)
|
|
63
65
|
const [refetch, isRefreshAnimating] = useRefreshAnimation(refetchRelease)
|
|
64
66
|
|
|
65
67
|
// Fetch manifest for selected revision (or latest)
|
|
66
68
|
const { data: manifest, isLoading: manifestLoading } = useHelmManifest(
|
|
67
|
-
|
|
69
|
+
helmNamespace,
|
|
68
70
|
release.name,
|
|
69
71
|
selectedRevision,
|
|
70
72
|
canViewSensitive,
|
|
@@ -72,7 +74,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
72
74
|
|
|
73
75
|
// Fetch values
|
|
74
76
|
const { data: values, isLoading: valuesLoading } = useHelmValues(
|
|
75
|
-
|
|
77
|
+
helmNamespace,
|
|
76
78
|
release.name,
|
|
77
79
|
showAllValues,
|
|
78
80
|
canViewSensitive,
|
|
@@ -80,7 +82,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
80
82
|
|
|
81
83
|
// Fetch diff if comparing revisions
|
|
82
84
|
const { data: diffData, isLoading: diffLoading } = useHelmManifestDiff(
|
|
83
|
-
|
|
85
|
+
helmNamespace,
|
|
84
86
|
release.name,
|
|
85
87
|
diffRevisions?.rev1 || 0,
|
|
86
88
|
diffRevisions?.rev2 || 0,
|
|
@@ -88,10 +90,11 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
88
90
|
)
|
|
89
91
|
|
|
90
92
|
// Lazy check for upgrade availability
|
|
91
|
-
const { data: upgradeInfo, isLoading: upgradeLoading } = useHelmUpgradeInfo(
|
|
92
|
-
|
|
93
|
+
const { data: upgradeInfo, isLoading: upgradeLoading, error: upgradeError } = useHelmUpgradeInfo(
|
|
94
|
+
helmNamespace,
|
|
93
95
|
release.name
|
|
94
96
|
)
|
|
97
|
+
const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade check failed'
|
|
95
98
|
|
|
96
99
|
// Mutations for actions
|
|
97
100
|
const uninstallMutation = useHelmUninstall()
|
|
@@ -148,12 +151,10 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
148
151
|
}, [])
|
|
149
152
|
|
|
150
153
|
const switchTab = useCallback((tab: TabId) => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
setActiveTab(tab)
|
|
156
|
-
}
|
|
154
|
+
// Swallow the InvalidStateError the API rejects with on rapid
|
|
155
|
+
// tab clicks (SKY-833 bug 49); fall back synchronously when the
|
|
156
|
+
// API isn't available.
|
|
157
|
+
startViewTransitionSafe(() => flushSync(() => setActiveTab(tab)))
|
|
157
158
|
}, [])
|
|
158
159
|
|
|
159
160
|
const handleCompareRevisions = (rev1: number, rev2: number) => {
|
|
@@ -177,7 +178,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
177
178
|
|
|
178
179
|
try {
|
|
179
180
|
await rollbackWithProgress(
|
|
180
|
-
|
|
181
|
+
helmNamespace,
|
|
181
182
|
release.name,
|
|
182
183
|
rollbackRevision,
|
|
183
184
|
(event) => {
|
|
@@ -196,7 +197,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
196
197
|
}])
|
|
197
198
|
|
|
198
199
|
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
199
|
-
queryClient.invalidateQueries({ queryKey: ['helm-release',
|
|
200
|
+
queryClient.invalidateQueries({ queryKey: ['helm-release', helmNamespace, release.name] })
|
|
200
201
|
|
|
201
202
|
setTimeout(() => {
|
|
202
203
|
setRollbackRevision(null)
|
|
@@ -216,7 +217,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
216
217
|
|
|
217
218
|
const handleUninstallConfirm = () => {
|
|
218
219
|
uninstallMutation.mutate(
|
|
219
|
-
{ namespace:
|
|
220
|
+
{ namespace: helmNamespace, name: release.name },
|
|
220
221
|
{
|
|
221
222
|
onSuccess: () => {
|
|
222
223
|
setShowUninstallConfirm(false)
|
|
@@ -236,9 +237,10 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
236
237
|
|
|
237
238
|
try {
|
|
238
239
|
await upgradeWithProgress(
|
|
239
|
-
|
|
240
|
+
helmNamespace,
|
|
240
241
|
release.name,
|
|
241
242
|
upgradeInfo.latestVersion,
|
|
243
|
+
upgradeInfo.repositoryName,
|
|
242
244
|
(event) => {
|
|
243
245
|
if (event.type === 'progress' && event.message) {
|
|
244
246
|
setUpgradeProgress(prev => [...prev, {
|
|
@@ -256,8 +258,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
256
258
|
|
|
257
259
|
// Invalidate queries
|
|
258
260
|
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
259
|
-
queryClient.invalidateQueries({ queryKey: ['helm-release',
|
|
260
|
-
queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info',
|
|
261
|
+
queryClient.invalidateQueries({ queryKey: ['helm-release', helmNamespace, release.name] })
|
|
262
|
+
queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info', helmNamespace, release.name] })
|
|
261
263
|
queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
|
|
262
264
|
|
|
263
265
|
setTimeout(() => {
|
|
@@ -328,6 +330,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
328
330
|
<span className="badge bg-theme-hover/50 text-theme-text-secondary animate-pulse">
|
|
329
331
|
checking...
|
|
330
332
|
</span>
|
|
333
|
+
) : upgradeError ? (
|
|
334
|
+
<span
|
|
335
|
+
className="badge bg-theme-hover/50 text-theme-text-secondary"
|
|
336
|
+
title={upgradeErrorMessage}
|
|
337
|
+
>
|
|
338
|
+
upgrade check failed
|
|
339
|
+
</span>
|
|
331
340
|
) : upgradeInfo?.updateAvailable ? (
|
|
332
341
|
<button
|
|
333
342
|
onClick={() => setShowUpgradeConfirm(true)}
|
|
@@ -345,6 +354,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
345
354
|
<span className={clsx('badge', SEVERITY_BADGE.success)} title="Chart is up to date">
|
|
346
355
|
latest
|
|
347
356
|
</span>
|
|
357
|
+
) : upgradeInfo?.error ? (
|
|
358
|
+
<span
|
|
359
|
+
className="badge bg-theme-hover/50 text-theme-text-secondary"
|
|
360
|
+
title={upgradeInfo.error}
|
|
361
|
+
>
|
|
362
|
+
upstream unknown
|
|
363
|
+
</span>
|
|
348
364
|
) : null}
|
|
349
365
|
</div>
|
|
350
366
|
<div className="flex items-center gap-1">
|
|
@@ -451,7 +467,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
451
467
|
onToggleAllValues={setShowAllValues}
|
|
452
468
|
onCopy={(text) => copyToClipboard(text, 'values')}
|
|
453
469
|
copied={copied === 'values'}
|
|
454
|
-
namespace={
|
|
470
|
+
namespace={helmNamespace}
|
|
455
471
|
name={release.name}
|
|
456
472
|
onApplySuccess={() => refetch()}
|
|
457
473
|
/>
|
|
@@ -17,7 +17,7 @@ type ViewTab = 'releases' | 'charts'
|
|
|
17
17
|
interface HelmViewProps {
|
|
18
18
|
namespace: string
|
|
19
19
|
selectedRelease?: SelectedHelmRelease | null
|
|
20
|
-
onReleaseClick?: (namespace: string, name: string) => void
|
|
20
|
+
onReleaseClick?: (namespace: string, name: string, storageNamespace?: string) => void
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmViewProps) {
|
|
@@ -27,12 +27,14 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
27
27
|
|
|
28
28
|
const { data: releases, isLoading, error: releasesError, refetch: refetchReleases } = useHelmReleases(namespace || undefined)
|
|
29
29
|
const isForbidden = isForbiddenError(releasesError)
|
|
30
|
+
const releasesErrorMessage = releasesError instanceof Error ? releasesError.message : 'Failed to load Helm releases'
|
|
30
31
|
|
|
31
32
|
// Lazy load upgrade info after releases are loaded
|
|
32
|
-
const { data: upgradeInfo, isLoading: upgradeLoading, refetch: refetchUpgradeInfo } = useHelmBatchUpgradeInfo(
|
|
33
|
+
const { data: upgradeInfo, isLoading: upgradeLoading, error: upgradeError, refetch: refetchUpgradeInfo } = useHelmBatchUpgradeInfo(
|
|
33
34
|
namespace || undefined,
|
|
34
35
|
Boolean(releases && releases.length > 0)
|
|
35
36
|
)
|
|
37
|
+
const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade checks failed'
|
|
36
38
|
|
|
37
39
|
const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(async () => {
|
|
38
40
|
await Promise.all([refetchReleases(), refetchUpgradeInfo()])
|
|
@@ -132,7 +134,7 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
132
134
|
scope: 'helm',
|
|
133
135
|
handler: () => {
|
|
134
136
|
const release = getHighlightedRelease()
|
|
135
|
-
if (release) onReleaseClick?.(release.namespace, release.name)
|
|
137
|
+
if (release) onReleaseClick?.(release.namespace, release.name, release.storageNamespace)
|
|
136
138
|
},
|
|
137
139
|
enabled: highlightedIndex >= 0,
|
|
138
140
|
},
|
|
@@ -232,6 +234,11 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
232
234
|
|
|
233
235
|
{/* Releases Table */}
|
|
234
236
|
<div className="flex-1 overflow-auto">
|
|
237
|
+
{upgradeError && (
|
|
238
|
+
<div className="m-4 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-300">
|
|
239
|
+
Upgrade checks failed: {upgradeErrorMessage}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
235
242
|
{isLoading ? (
|
|
236
243
|
<PaneLoader className="h-full" />
|
|
237
244
|
) : isForbidden ? (
|
|
@@ -240,6 +247,20 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
240
247
|
<p className="text-theme-text-secondary font-medium">Access Restricted</p>
|
|
241
248
|
<p className="text-sm mt-1">Insufficient permissions to list Helm releases</p>
|
|
242
249
|
</div>
|
|
250
|
+
) : releasesError ? (
|
|
251
|
+
<div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary gap-3 px-6 text-center">
|
|
252
|
+
<Package className="w-10 h-10 text-amber-400" />
|
|
253
|
+
<div>
|
|
254
|
+
<p className="text-theme-text-secondary font-medium">Failed to load Helm releases</p>
|
|
255
|
+
<p className="text-sm mt-1 break-all">{releasesErrorMessage}</p>
|
|
256
|
+
</div>
|
|
257
|
+
<button
|
|
258
|
+
onClick={() => refetchReleases()}
|
|
259
|
+
className="px-3 py-1.5 text-sm text-theme-text-primary border border-theme-border rounded-lg hover:bg-theme-elevated transition-colors"
|
|
260
|
+
>
|
|
261
|
+
Retry
|
|
262
|
+
</button>
|
|
263
|
+
</div>
|
|
243
264
|
) : filteredReleases.length === 0 ? (
|
|
244
265
|
<div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary gap-2">
|
|
245
266
|
<Package className="w-12 h-12 text-theme-text-disabled" />
|
|
@@ -291,16 +312,17 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
291
312
|
<tbody className="table-divide-subtle">
|
|
292
313
|
{filteredReleases.map((release, index) => (
|
|
293
314
|
<ReleaseRow
|
|
294
|
-
key={
|
|
315
|
+
key={releaseIdentityKey(release)}
|
|
295
316
|
ref={index === highlightedIndex ? highlightedRowRef : null}
|
|
296
317
|
release={release}
|
|
297
|
-
upgradeInfo={upgradeInfo?.releases[
|
|
318
|
+
upgradeInfo={upgradeInfo?.releases[releaseIdentityKey(release)]}
|
|
298
319
|
isSelected={
|
|
299
320
|
selectedRelease?.namespace === release.namespace &&
|
|
300
|
-
selectedRelease?.name === release.name
|
|
321
|
+
selectedRelease?.name === release.name &&
|
|
322
|
+
(selectedRelease?.storageNamespace || selectedRelease?.namespace) === (release.storageNamespace || release.namespace)
|
|
301
323
|
}
|
|
302
324
|
isHighlighted={index === highlightedIndex}
|
|
303
|
-
onClick={() => onReleaseClick?.(release.namespace, release.name)}
|
|
325
|
+
onClick={() => onReleaseClick?.(release.namespace, release.name, release.storageNamespace)}
|
|
304
326
|
onMouseEnter={() => setHighlightedIndex(-1)}
|
|
305
327
|
/>
|
|
306
328
|
))}
|
|
@@ -329,6 +351,10 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
329
351
|
)
|
|
330
352
|
}
|
|
331
353
|
|
|
354
|
+
function releaseIdentityKey(release: Pick<HelmRelease, 'namespace' | 'name' | 'storageNamespace'>): string {
|
|
355
|
+
return `${release.storageNamespace || release.namespace}/${release.name}`
|
|
356
|
+
}
|
|
357
|
+
|
|
332
358
|
interface ReleaseRowProps {
|
|
333
359
|
release: HelmRelease
|
|
334
360
|
upgradeInfo?: UpgradeInfo
|
|
@@ -12,6 +12,7 @@ import { YamlEditor } from '../ui/YamlEditor'
|
|
|
12
12
|
import { Tooltip } from '../ui/Tooltip'
|
|
13
13
|
import { Markdown } from '../ui/Markdown'
|
|
14
14
|
import { SEVERITY_BADGE, SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
15
|
+
import { validateHelmReleaseName, validateRFC1123Label } from '@skyhook-io/k8s-ui/utils/validators'
|
|
15
16
|
|
|
16
17
|
// Deep merge two objects — values from `overrides` take priority
|
|
17
18
|
function deepMerge(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -134,11 +135,20 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
134
135
|
setInstallError(null)
|
|
135
136
|
setProgressLogs([])
|
|
136
137
|
|
|
138
|
+
// Send the same trimmed values the validators ran on, not the
|
|
139
|
+
// raw input. Without this, a release name with trailing
|
|
140
|
+
// whitespace passes the client-side validator (which calls
|
|
141
|
+
// `.trim()`) but the server receives the untrimmed string and
|
|
142
|
+
// rejects it — exactly the surprise this validation layer
|
|
143
|
+
// exists to prevent.
|
|
144
|
+
const trimmedReleaseName = releaseName.trim()
|
|
145
|
+
const trimmedNamespace = namespace.trim()
|
|
146
|
+
|
|
137
147
|
try {
|
|
138
148
|
const release = await installChartWithProgress(
|
|
139
149
|
{
|
|
140
|
-
releaseName,
|
|
141
|
-
namespace,
|
|
150
|
+
releaseName: trimmedReleaseName,
|
|
151
|
+
namespace: trimmedNamespace,
|
|
142
152
|
chartName,
|
|
143
153
|
version,
|
|
144
154
|
repository,
|
|
@@ -169,7 +179,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
169
179
|
|
|
170
180
|
// Wait a moment to show success, then close
|
|
171
181
|
setTimeout(() => {
|
|
172
|
-
onSuccess(
|
|
182
|
+
onSuccess(trimmedNamespace, trimmedReleaseName)
|
|
173
183
|
}, 1500)
|
|
174
184
|
} catch (err) {
|
|
175
185
|
setInstallError(err instanceof Error ? err.message : 'Install failed')
|
|
@@ -183,7 +193,23 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
183
193
|
}
|
|
184
194
|
}, [releaseName, namespace, chartName, version, repo, valuesYaml, createNamespace, onSuccess, isLocal, artifactHubDetail, queryClient])
|
|
185
195
|
|
|
186
|
-
|
|
196
|
+
// Validate release name + namespace before letting the user
|
|
197
|
+
// advance. Without this, a name like "Invalid Name With Spaces!"
|
|
198
|
+
// was accepted through to step 2 and only failed server-side at
|
|
199
|
+
// install time — the server returned a 422 the user couldn't
|
|
200
|
+
// connect back to anything they typed. K8s / Helm rules are
|
|
201
|
+
// pinned in packages/k8s-ui/src/utils/validators.ts.
|
|
202
|
+
const releaseNameValidation = useMemo(
|
|
203
|
+
() => validateHelmReleaseName(releaseName.trim()),
|
|
204
|
+
[releaseName],
|
|
205
|
+
)
|
|
206
|
+
const namespaceValidation = useMemo(
|
|
207
|
+
() => validateRFC1123Label(namespace.trim()),
|
|
208
|
+
[namespace],
|
|
209
|
+
)
|
|
210
|
+
const releaseNameError = releaseNameValidation.valid ? null : releaseNameValidation.error
|
|
211
|
+
const namespaceError = namespaceValidation.valid ? null : namespaceValidation.error
|
|
212
|
+
const canProceedFromInfo = releaseNameValidation.valid && namespaceValidation.valid
|
|
187
213
|
const canInstall = canProceedFromInfo && !yamlError
|
|
188
214
|
|
|
189
215
|
const steps: { id: WizardStep; label: string }[] = [
|
|
@@ -206,7 +232,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
206
232
|
<img
|
|
207
233
|
src={isLocal ? (chartDetail as ChartDetail)?.icon : (chartDetail as ArtifactHubChartDetail)?.logoUrl}
|
|
208
234
|
alt=""
|
|
209
|
-
className="w-8 h-8 rounded object-contain bg-
|
|
235
|
+
className="w-8 h-8 rounded object-contain bg-theme-elevated p-1"
|
|
210
236
|
/>
|
|
211
237
|
) : (
|
|
212
238
|
<Package className="w-8 h-8 text-purple-400" />
|
|
@@ -216,7 +242,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
216
242
|
<h2 className="text-lg font-semibold text-theme-text-primary">Install {chartName}</h2>
|
|
217
243
|
{!isLocal && (
|
|
218
244
|
<Tooltip content="From ArtifactHub">
|
|
219
|
-
<Globe className="w-4 h-4 text-
|
|
245
|
+
<Globe className="w-4 h-4 text-accent" />
|
|
220
246
|
</Tooltip>
|
|
221
247
|
)}
|
|
222
248
|
</div>
|
|
@@ -224,7 +250,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
224
250
|
<span>{repo} / {version}</span>
|
|
225
251
|
{!isLocal && (chartDetail as ArtifactHubChartDetail)?.repository?.official && (
|
|
226
252
|
<Tooltip content="Official">
|
|
227
|
-
<BadgeCheck className="w-3.5 h-3.5 text-
|
|
253
|
+
<BadgeCheck className="w-3.5 h-3.5 text-accent" />
|
|
228
254
|
</Tooltip>
|
|
229
255
|
)}
|
|
230
256
|
{!isLocal && (chartDetail as ArtifactHubChartDetail)?.repository?.verifiedPublisher && (
|
|
@@ -264,7 +290,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
264
290
|
>
|
|
265
291
|
<span className={clsx(
|
|
266
292
|
'w-5 h-5 rounded-full flex items-center justify-center text-xs font-medium',
|
|
267
|
-
step === s.id ? 'bg-
|
|
293
|
+
step === s.id ? 'bg-accent text-white' : 'bg-theme-elevated text-theme-text-secondary'
|
|
268
294
|
)}>
|
|
269
295
|
{i + 1}
|
|
270
296
|
</span>
|
|
@@ -287,8 +313,10 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
287
313
|
source={source}
|
|
288
314
|
releaseName={releaseName}
|
|
289
315
|
setReleaseName={setReleaseName}
|
|
316
|
+
releaseNameError={releaseNameError}
|
|
290
317
|
namespace={namespace}
|
|
291
318
|
setNamespace={setNamespace}
|
|
319
|
+
namespaceError={namespaceError}
|
|
292
320
|
namespaces={namespaces || []}
|
|
293
321
|
createNamespace={createNamespace}
|
|
294
322
|
setCreateNamespace={setCreateNamespace}
|
|
@@ -420,8 +448,10 @@ interface InfoStepProps {
|
|
|
420
448
|
source: ChartSource
|
|
421
449
|
releaseName: string
|
|
422
450
|
setReleaseName: (name: string) => void
|
|
451
|
+
releaseNameError: string | null
|
|
423
452
|
namespace: string
|
|
424
453
|
setNamespace: (ns: string) => void
|
|
454
|
+
namespaceError: string | null
|
|
425
455
|
namespaces: { name: string }[]
|
|
426
456
|
createNamespace: boolean
|
|
427
457
|
setCreateNamespace: (create: boolean) => void
|
|
@@ -434,8 +464,10 @@ function InfoStep({
|
|
|
434
464
|
source,
|
|
435
465
|
releaseName,
|
|
436
466
|
setReleaseName,
|
|
467
|
+
releaseNameError,
|
|
437
468
|
namespace,
|
|
438
469
|
setNamespace,
|
|
470
|
+
namespaceError,
|
|
439
471
|
namespaces,
|
|
440
472
|
createNamespace,
|
|
441
473
|
setCreateNamespace,
|
|
@@ -506,7 +538,7 @@ function InfoStep({
|
|
|
506
538
|
href={home}
|
|
507
539
|
target="_blank"
|
|
508
540
|
rel="noopener noreferrer"
|
|
509
|
-
className="flex items-center gap-1 text-xs text-
|
|
541
|
+
className="flex items-center gap-1 text-xs text-accent-text hover:underline"
|
|
510
542
|
>
|
|
511
543
|
<LinkIcon className="w-3.5 h-3.5" />
|
|
512
544
|
Homepage
|
|
@@ -533,11 +565,24 @@ function InfoStep({
|
|
|
533
565
|
value={releaseName}
|
|
534
566
|
onChange={(e) => setReleaseName(e.target.value)}
|
|
535
567
|
placeholder="my-release"
|
|
536
|
-
|
|
568
|
+
aria-invalid={releaseNameError ? true : undefined}
|
|
569
|
+
aria-describedby="release-name-help"
|
|
570
|
+
className={clsx(
|
|
571
|
+
'w-full 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',
|
|
572
|
+
releaseNameError
|
|
573
|
+
? 'border-red-500/60 focus:ring-red-500'
|
|
574
|
+
: 'border-theme-border-light focus:ring-accent',
|
|
575
|
+
)}
|
|
537
576
|
/>
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
577
|
+
{releaseNameError ? (
|
|
578
|
+
<p id="release-name-help" className="mt-1 text-xs text-red-400">
|
|
579
|
+
Release name {releaseNameError}.
|
|
580
|
+
</p>
|
|
581
|
+
) : (
|
|
582
|
+
<p id="release-name-help" className="mt-1 text-xs text-theme-text-tertiary">
|
|
583
|
+
A unique name for this release in the namespace
|
|
584
|
+
</p>
|
|
585
|
+
)}
|
|
541
586
|
</div>
|
|
542
587
|
|
|
543
588
|
{/* Namespace selection */}
|
|
@@ -551,8 +596,20 @@ function InfoStep({
|
|
|
551
596
|
value={namespace}
|
|
552
597
|
onChange={(e) => setNamespace(e.target.value)}
|
|
553
598
|
placeholder="Enter namespace name"
|
|
554
|
-
|
|
599
|
+
aria-invalid={namespaceError ? true : undefined}
|
|
600
|
+
aria-describedby="namespace-help"
|
|
601
|
+
className={clsx(
|
|
602
|
+
'w-full 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',
|
|
603
|
+
namespaceError
|
|
604
|
+
? 'border-red-500/60 focus:ring-red-500'
|
|
605
|
+
: 'border-theme-border-light focus:ring-accent',
|
|
606
|
+
)}
|
|
555
607
|
/>
|
|
608
|
+
{namespaceError && (
|
|
609
|
+
<p id="namespace-help" className="mt-1 text-xs text-red-400">
|
|
610
|
+
Namespace {namespaceError}.
|
|
611
|
+
</p>
|
|
612
|
+
)}
|
|
556
613
|
<datalist id="namespace-suggestions">
|
|
557
614
|
{namespaces.map(ns => (
|
|
558
615
|
<option key={ns.name} value={ns.name} />
|
|
@@ -563,7 +620,7 @@ function InfoStep({
|
|
|
563
620
|
type="checkbox"
|
|
564
621
|
checked={createNamespace}
|
|
565
622
|
onChange={(e) => setCreateNamespace(e.target.checked)}
|
|
566
|
-
className="rounded border-theme-border text-
|
|
623
|
+
className="rounded border-theme-border text-accent focus:ring-accent"
|
|
567
624
|
/>
|
|
568
625
|
Create namespace if it doesn't exist
|
|
569
626
|
</label>
|
|
@@ -574,7 +631,7 @@ function InfoStep({
|
|
|
574
631
|
<div>
|
|
575
632
|
<button
|
|
576
633
|
onClick={() => setShowReadme(!showReadme)}
|
|
577
|
-
className="flex items-center gap-2 text-sm text-
|
|
634
|
+
className="flex items-center gap-2 text-sm text-accent-text hover:underline"
|
|
578
635
|
>
|
|
579
636
|
<BookOpen className="w-4 h-4" />
|
|
580
637
|
{showReadme ? 'Hide' : 'Show'} Chart README
|
|
@@ -619,8 +676,8 @@ function ValuesStep({ valuesYaml, setValuesYaml, yamlError, setYamlError, chartD
|
|
|
619
676
|
return (
|
|
620
677
|
<div className="space-y-4">
|
|
621
678
|
{/* Info banner */}
|
|
622
|
-
<div className="flex items-start gap-3 p-4 bg-
|
|
623
|
-
<CheckCircle className="w-5 h-5 text-
|
|
679
|
+
<div className="flex items-start gap-3 p-4 bg-accent-muted border border-accent/30 rounded-lg">
|
|
680
|
+
<CheckCircle className="w-5 h-5 text-accent shrink-0 mt-0.5" />
|
|
624
681
|
<div>
|
|
625
682
|
<p className="text-sm font-medium text-theme-text-primary">Ready to install with defaults</p>
|
|
626
683
|
<p className="text-xs text-theme-text-secondary mt-1">
|
|
@@ -633,7 +690,7 @@ function ValuesStep({ valuesYaml, setValuesYaml, yamlError, setYamlError, chartD
|
|
|
633
690
|
href={homeUrl}
|
|
634
691
|
target="_blank"
|
|
635
692
|
rel="noopener noreferrer"
|
|
636
|
-
className="inline-flex items-center gap-1 text-xs text-
|
|
693
|
+
className="inline-flex items-center gap-1 text-xs text-accent-text hover:underline mt-2"
|
|
637
694
|
>
|
|
638
695
|
<LinkIcon className="w-3 h-3" />
|
|
639
696
|
View chart documentation
|
|
@@ -819,8 +876,8 @@ function ReviewStep({
|
|
|
819
876
|
|
|
820
877
|
return (
|
|
821
878
|
<div className="space-y-6">
|
|
822
|
-
<div className="flex items-center gap-3 p-4 bg-
|
|
823
|
-
<CheckCircle className="w-6 h-6 text-
|
|
879
|
+
<div className="flex items-center gap-3 p-4 bg-accent-muted border border-accent/30 rounded-lg">
|
|
880
|
+
<CheckCircle className="w-6 h-6 text-accent shrink-0" />
|
|
824
881
|
<div>
|
|
825
882
|
<p className="text-sm font-medium text-theme-text-primary">Ready to install</p>
|
|
826
883
|
<p className="text-xs text-theme-text-secondary mt-0.5">
|
|
@@ -873,7 +930,7 @@ function ReviewStep({
|
|
|
873
930
|
<>Local: {repo}</>
|
|
874
931
|
) : (
|
|
875
932
|
<>
|
|
876
|
-
<Globe className="w-3.5 h-3.5 text-
|
|
933
|
+
<Globe className="w-3.5 h-3.5 text-accent" />
|
|
877
934
|
ArtifactHub: {repo}
|
|
878
935
|
</>
|
|
879
936
|
)}
|
|
@@ -11,7 +11,7 @@ import { NetworkPolicyCoverageCard } from './NetworkPolicyCoverageCard'
|
|
|
11
11
|
import { CostCard } from './CostCard'
|
|
12
12
|
import { AuditCard, PaneLoader, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui'
|
|
13
13
|
import { ClusterHealthCard } from './ClusterHealthCard'
|
|
14
|
-
import { AlertTriangle, Shield } from 'lucide-react'
|
|
14
|
+
import { AlertTriangle, Loader2, Shield } from 'lucide-react'
|
|
15
15
|
import { clsx } from 'clsx'
|
|
16
16
|
|
|
17
17
|
interface HomeViewProps {
|
|
@@ -58,9 +58,21 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
|
|
|
58
58
|
|
|
59
59
|
const hasProblems = data.problems && data.problems.length > 0
|
|
60
60
|
|
|
61
|
+
const stillLoading = data.deferredLoading || (data.partialData && data.partialData.length > 0)
|
|
62
|
+
|
|
61
63
|
return (
|
|
62
64
|
<div className="flex-1 overflow-y-auto">
|
|
63
65
|
<div className="max-w-[1600px] mx-auto px-6 py-6 space-y-6">
|
|
66
|
+
{stillLoading && (
|
|
67
|
+
<div className="flex items-center gap-2 text-xs text-theme-text-tertiary">
|
|
68
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
69
|
+
<span>
|
|
70
|
+
{data.partialData && data.partialData.length > 0
|
|
71
|
+
? `Still loading: ${data.partialData.join(', ')}`
|
|
72
|
+
: 'Loading remaining resources…'}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
64
76
|
{/* Row 1: Cluster Health Card (combined health + resource counts) */}
|
|
65
77
|
<ClusterHealthCard
|
|
66
78
|
health={data.health}
|
|
@@ -3,6 +3,7 @@ import { Plug, ChevronDown, Loader2, Globe, Monitor, Copy, Check, X, Terminal }
|
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { useAvailablePorts, useClusterInfo, AvailablePort } from '../../api/client'
|
|
5
5
|
import { useStartPortForward } from './PortForwardManager'
|
|
6
|
+
import { validatePort } from '@skyhook-io/k8s-ui/utils/validators'
|
|
6
7
|
|
|
7
8
|
interface PortForwardButtonProps {
|
|
8
9
|
type: 'pod' | 'service'
|
|
@@ -35,7 +36,13 @@ function KubectlCommandDialog({
|
|
|
35
36
|
}) {
|
|
36
37
|
const [copied, setCopied] = useState(false)
|
|
37
38
|
const [copyFallback, setCopyFallback] = useState(false)
|
|
38
|
-
|
|
39
|
+
// Track raw input separately from the validated port so the user
|
|
40
|
+
// always sees the characters they typed; the validated port (used to
|
|
41
|
+
// build the command) only updates when the input parses cleanly.
|
|
42
|
+
const [portInput, setPortInput] = useState(String(info.port))
|
|
43
|
+
const portValidation = validatePort(portInput)
|
|
44
|
+
const localPort = portValidation.valid ? portValidation.value : info.port
|
|
45
|
+
const portError = portValidation.valid ? null : portValidation.error
|
|
39
46
|
const commandRef = useRef<HTMLElement>(null)
|
|
40
47
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
41
48
|
|
|
@@ -97,21 +104,35 @@ function KubectlCommandDialog({
|
|
|
97
104
|
<p className="text-sm text-theme-text-secondary">
|
|
98
105
|
Radar is running in-cluster, so port forwarding must be run from your local terminal.
|
|
99
106
|
</p>
|
|
100
|
-
<div className="flex
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
<div className="flex flex-col gap-1">
|
|
108
|
+
<div className="flex items-center gap-2 text-sm text-theme-text-secondary">
|
|
109
|
+
<label htmlFor="local-port">Local port:</label>
|
|
110
|
+
<input
|
|
111
|
+
id="local-port"
|
|
112
|
+
type="text"
|
|
113
|
+
inputMode="numeric"
|
|
114
|
+
value={portInput}
|
|
115
|
+
onChange={(e) => setPortInput(e.target.value)}
|
|
116
|
+
aria-invalid={portError ? true : undefined}
|
|
117
|
+
aria-describedby="local-port-help"
|
|
118
|
+
className={clsx(
|
|
119
|
+
'w-24 bg-theme-base border rounded px-2 py-1 text-sm text-theme-text-primary font-mono text-center',
|
|
120
|
+
portError
|
|
121
|
+
? 'border-red-500/60 focus:outline-none focus:ring-2 focus:ring-red-500'
|
|
122
|
+
: 'border-theme-border',
|
|
123
|
+
)}
|
|
124
|
+
/>
|
|
125
|
+
{portError && (
|
|
126
|
+
<span className="text-xs text-red-400">
|
|
127
|
+
using {info.port}
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
{portError && (
|
|
132
|
+
<p id="local-port-help" className="text-xs text-red-400">
|
|
133
|
+
{portError.charAt(0).toUpperCase() + portError.slice(1)}.
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
115
136
|
</div>
|
|
116
137
|
<div className="flex items-center gap-2">
|
|
117
138
|
<code ref={commandRef} className="flex-1 text-sm bg-theme-base rounded px-3 py-2 text-blue-400 font-mono select-all">
|