@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.
@@ -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
- release.namespace,
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
- release.namespace,
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
- release.namespace,
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
- release.namespace,
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
- release.namespace,
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
- const update = () => flushSync(() => setActiveTab(tab))
152
- if (document.startViewTransition) {
153
- document.startViewTransition(update)
154
- } else {
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
- release.namespace,
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', release.namespace, release.name] })
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: release.namespace, name: release.name },
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
- release.namespace,
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', release.namespace, release.name] })
260
- queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info', release.namespace, release.name] })
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={release.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={`${release.namespace}-${release.name}`}
315
+ key={releaseIdentityKey(release)}
295
316
  ref={index === highlightedIndex ? highlightedRowRef : null}
296
317
  release={release}
297
- upgradeInfo={upgradeInfo?.releases[`${release.namespace}/${release.name}`]}
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(namespace, releaseName)
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
- const canProceedFromInfo = releaseName.trim() !== '' && namespace.trim() !== ''
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-white/10 p-1"
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-blue-400" />
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-blue-400" />
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-blue-500 text-white' : 'bg-theme-elevated text-theme-text-secondary'
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-blue-400 hover:text-blue-300"
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
- className="w-full px-3 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
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
- <p className="mt-1 text-xs text-theme-text-tertiary">
539
- A unique name for this release in the namespace
540
- </p>
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
- className="w-full px-3 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
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-blue-500 focus:ring-blue-500"
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-blue-400 hover:text-blue-300"
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-blue-500/10 border border-blue-500/30 rounded-lg">
623
- <CheckCircle className="w-5 h-5 text-blue-400 shrink-0 mt-0.5" />
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-blue-400 hover:text-blue-300 mt-2"
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-blue-500/10 border border-blue-500/30 rounded-lg">
823
- <CheckCircle className="w-6 h-6 text-blue-400 shrink-0" />
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-blue-400" />
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
- const [localPort, setLocalPort] = useState(info.port)
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 items-center gap-2 text-sm text-theme-text-secondary">
101
- <label htmlFor="local-port">Local port:</label>
102
- <input
103
- id="local-port"
104
- type="number"
105
- min={1}
106
- max={65535}
107
- value={localPort}
108
- onChange={(e) => {
109
- const val = Number(e.target.value)
110
- if (val >= 1 && val <= 65535) setLocalPort(val)
111
- else if (e.target.value === '') setLocalPort(info.port)
112
- }}
113
- className="w-20 bg-theme-base border border-theme-border rounded px-2 py-1 text-sm text-theme-text-primary font-mono text-center"
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">