@skyhook-io/radar-app 0.2.2 → 1.0.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "0.2.2",
3
+ "version": "1.0.0",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/App.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
3
  import { useRefreshAnimation } from './hooks/useRefreshAnimation'
4
+ import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
4
5
  import { useQueryClient } from '@tanstack/react-query'
5
6
  import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
6
7
  import { HomeView } from './components/home/HomeView'
@@ -345,8 +346,13 @@ function AppInner() {
345
346
  // Navigate to a resource — uses View Transitions cross-fade when drawer is already open
346
347
  const navigateToResource = useCallback((res: SelectedResource, tab: 'detail' | 'yaml' = 'detail') => {
347
348
  const update = () => { setDrawerInitialTab(tab); setSelectedResource(res) }
348
- if (selectedResource && document.startViewTransition) {
349
- document.startViewTransition(() => flushSync(update))
349
+ // Skip the cross-fade animation entirely on first open (no
350
+ // `selectedResource`); otherwise route through
351
+ // startViewTransitionSafe to swallow the InvalidStateError that
352
+ // the API rejects with on rapid back-to-back navigations.
353
+ // (SKY-833 bug 49)
354
+ if (selectedResource) {
355
+ startViewTransitionSafe(() => flushSync(update))
350
356
  } else {
351
357
  update()
352
358
  }
package/src/api/client.ts CHANGED
@@ -272,7 +272,7 @@ export interface DashboardResponse {
272
272
  audit: DashboardAudit | null
273
273
  nodeVersionSkew: { versions: Record<string, string[]>; minVersion: string; maxVersion: string } | null
274
274
  deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
275
- partialData?: string[] // Resource kinds still loading after first paint (slow-cluster fallback)
275
+ partialData?: string[] // Critical kinds promoted at first paint that haven't yet finished syncing (live-filtered)
276
276
  accessRestricted?: boolean // True when user has no namespace access (RBAC)
277
277
  }
278
278
 
@@ -2603,6 +2603,31 @@ export interface DiagErrorEntry {
2603
2603
  level: string
2604
2604
  }
2605
2605
 
2606
+ export type DiagSyncPhase = 'not_started' | 'syncing_critical' | 'syncing_deferred' | 'complete'
2607
+
2608
+ export interface DiagInformerSyncStatus {
2609
+ kind: string
2610
+ key: string
2611
+ deferred: boolean
2612
+ synced: boolean
2613
+ syncedAt?: string
2614
+ items: number
2615
+ }
2616
+
2617
+ export interface DiagCacheSyncStatus {
2618
+ phase: DiagSyncPhase
2619
+ syncStarted?: string
2620
+ elapsedSec: number
2621
+ criticalTotal: number
2622
+ criticalSynced: number
2623
+ deferredTotal: number
2624
+ deferredSynced: number
2625
+ informers: DiagInformerSyncStatus[]
2626
+ pendingCritical?: string[]
2627
+ pendingDeferred?: string[]
2628
+ promotedKinds?: string[]
2629
+ }
2630
+
2606
2631
  export interface DiagnosticsSnapshot {
2607
2632
  timestamp: string
2608
2633
  radarVersion: string
@@ -2666,6 +2691,7 @@ export interface DiagnosticsSnapshot {
2666
2691
  typedCount: number
2667
2692
  dynamicCount: number
2668
2693
  watchedCRDs: string[]
2694
+ syncStatus?: DiagCacheSyncStatus
2669
2695
  }
2670
2696
  prometheus?: {
2671
2697
  connected: boolean
@@ -1,7 +1,9 @@
1
- import { useState, useEffect } from 'react'
1
+ import { useState, useEffect, useMemo } from 'react'
2
2
  import { X, Plus, Trash2 } from 'lucide-react'
3
+ import { clsx } from 'clsx'
3
4
  import { useAuditSettings, useUpdateAuditSettings, useAudit } from '../../api/client'
4
5
  import type { CheckMeta } from '@skyhook-io/k8s-ui'
6
+ import { validateRFC1123Label, type ValidationResult } from '@skyhook-io/k8s-ui/utils/validators'
5
7
 
6
8
  interface AuditSettingsDialogProps {
7
9
  namespaces: string[]
@@ -28,12 +30,23 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
28
30
  ? Object.values(auditData.checks).sort((a, b) => a.title.localeCompare(b.title))
29
31
  : []
30
32
 
33
+ // Validate the staged namespace input against RFC 1123. Saving a bogus
34
+ // entry would silently never match anything in the audit pipeline,
35
+ // leaving the user thinking the ignore filter doesn't work.
36
+ const newNsTrimmed = newNs.trim()
37
+ const newNsValidation = useMemo<ValidationResult>(
38
+ () => (newNsTrimmed === '' ? { valid: true } : validateRFC1123Label(newNsTrimmed)),
39
+ [newNsTrimmed],
40
+ )
41
+ const newNsError = newNsValidation.valid ? null : newNsValidation.error
42
+ const newNsDuplicate = newNsTrimmed !== '' && ignoredNs.includes(newNsTrimmed)
43
+ const canAddNamespace =
44
+ newNsTrimmed !== '' && newNsValidation.valid && !newNsDuplicate
45
+
31
46
  const addNamespace = () => {
32
- const ns = newNs.trim()
33
- if (ns && !ignoredNs.includes(ns)) {
34
- setIgnoredNs([...ignoredNs, ns])
35
- setNewNs('')
36
- }
47
+ if (!canAddNamespace) return
48
+ setIgnoredNs([...ignoredNs, newNsTrimmed])
49
+ setNewNs('')
37
50
  }
38
51
 
39
52
  const toggleCheck = (checkID: string) => {
@@ -95,16 +108,30 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
95
108
  onChange={e => setNewNs(e.target.value)}
96
109
  onKeyDown={e => { if (e.key === 'Enter') addNamespace() }}
97
110
  placeholder="Add namespace..."
98
- className="flex-1 px-3 py-1.5 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-skyhook-500"
111
+ aria-invalid={newNsError ? true : undefined}
112
+ aria-describedby="new-ns-help"
113
+ className={clsx(
114
+ 'flex-1 px-3 py-1.5 bg-theme-elevated border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2',
115
+ newNsError || newNsDuplicate
116
+ ? 'border-red-500/60 focus:ring-red-500'
117
+ : 'border-theme-border-light focus:ring-skyhook-500',
118
+ )}
99
119
  />
100
120
  <button
101
121
  onClick={addNamespace}
102
- disabled={!newNs.trim()}
122
+ disabled={!canAddNamespace}
103
123
  className="px-3 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
104
124
  >
105
125
  <Plus className="w-4 h-4" />
106
126
  </button>
107
127
  </div>
128
+ {(newNsError || newNsDuplicate) && (
129
+ <p id="new-ns-help" className="mt-1.5 text-xs text-red-400">
130
+ {newNsDuplicate
131
+ ? `"${newNsTrimmed}" is already in the list.`
132
+ : `Namespace ${newNsError}.`}
133
+ </p>
134
+ )}
108
135
  </div>
109
136
 
110
137
  {/* Disabled Checks */}
@@ -150,8 +177,20 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
150
177
  </button>
151
178
  <button
152
179
  onClick={handleSave}
153
- disabled={updateSettings.isPending}
154
- className="px-4 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50"
180
+ // Block save while the namespace input has unfixed pending
181
+ // text otherwise the user clicks Save expecting their
182
+ // entry to be included and it's silently dropped.
183
+ disabled={
184
+ updateSettings.isPending || newNsError !== null || newNsDuplicate
185
+ }
186
+ title={
187
+ newNsError
188
+ ? 'Fix or clear the pending namespace input before saving'
189
+ : newNsDuplicate
190
+ ? 'Clear the duplicate pending input before saving'
191
+ : undefined
192
+ }
193
+ className="px-4 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
155
194
  >
156
195
  {updateSettings.isPending ? 'Saving...' : 'Save'}
157
196
  </button>
@@ -148,7 +148,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
148
148
  onClick={() => { setSelectedRepo('all'); setRepoDropdownOpen(false) }}
149
149
  className={clsx(
150
150
  'w-full px-3 py-2 text-left text-sm hover:bg-theme-hover flex items-center justify-between',
151
- selectedRepo === 'all' ? 'text-blue-400' : 'text-theme-text-primary'
151
+ selectedRepo === 'all' ? 'text-accent' : 'text-theme-text-primary'
152
152
  )}
153
153
  >
154
154
  <span>All Repositories</span>
@@ -186,9 +186,9 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
186
186
  type="checkbox"
187
187
  checked={showOfficialOnly}
188
188
  onChange={(e) => setShowOfficialOnly(e.target.checked)}
189
- className="rounded border-theme-border text-blue-500 focus:ring-blue-500"
189
+ className="rounded border-theme-border text-accent focus:ring-accent"
190
190
  />
191
- <BadgeCheck className="w-3.5 h-3.5 text-blue-400" />
191
+ <BadgeCheck className="w-3.5 h-3.5 text-accent" />
192
192
  Official
193
193
  </label>
194
194
  <label className="flex items-center gap-1.5 text-sm text-theme-text-secondary">
@@ -196,7 +196,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
196
196
  type="checkbox"
197
197
  checked={showVerifiedOnly}
198
198
  onChange={(e) => setShowVerifiedOnly(e.target.checked)}
199
- className="rounded border-theme-border text-blue-500 focus:ring-blue-500"
199
+ className="rounded border-theme-border text-accent focus:ring-accent"
200
200
  />
201
201
  <Shield className="w-3.5 h-3.5 text-green-400" />
202
202
  Verified
@@ -207,7 +207,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
207
207
  <select
208
208
  value={artifactHubSort}
209
209
  onChange={(e) => setArtifactHubSort(e.target.value as ArtifactHubSortOption)}
210
- className="bg-theme-elevated border border-theme-border-light rounded px-2 py-1 text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-blue-500"
210
+ className="bg-theme-elevated border border-theme-border-light rounded px-2 py-1 text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
211
211
  >
212
212
  <option value="relevance">Relevance</option>
213
213
  <option value="stars">Stars</option>
@@ -225,7 +225,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
225
225
  placeholder={chartSource === 'local' ? "Search charts..." : "Search ArtifactHub..."}
226
226
  value={searchTerm}
227
227
  onChange={(e) => setSearchTerm(e.target.value)}
228
- className="w-full max-w-md pl-10 pr-4 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"
228
+ className="w-full max-w-md pl-10 pr-4 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-accent"
229
229
  />
230
230
  </div>
231
231
 
@@ -237,7 +237,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
237
237
  type="checkbox"
238
238
  checked={showAllVersions}
239
239
  onChange={(e) => setShowAllVersions(e.target.checked)}
240
- className="rounded border-theme-border text-blue-500 focus:ring-blue-500"
240
+ className="rounded border-theme-border text-accent focus:ring-accent"
241
241
  />
242
242
  All versions
243
243
  </label>
@@ -276,7 +276,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
276
276
  Add repositories using <code className="bg-theme-elevated px-1 rounded">helm repo add</code>
277
277
  </p>
278
278
  <p className="mt-2">
279
- Or try searching on <button onClick={() => setChartSource('artifacthub')} className="text-blue-400 hover:underline">ArtifactHub</button>
279
+ Or try searching on <button onClick={() => setChartSource('artifacthub')} className="text-accent-text hover:underline">ArtifactHub</button>
280
280
  </p>
281
281
  </div>
282
282
  ) : (
@@ -375,7 +375,7 @@ function RepoDropdownItem({ repo, isSelected, onSelect, onUpdate, isUpdating, ca
375
375
  onClick={onSelect}
376
376
  className={clsx(
377
377
  'flex-1 text-left text-sm truncate',
378
- isSelected ? 'text-blue-400' : 'text-theme-text-primary'
378
+ isSelected ? 'text-accent' : 'text-theme-text-primary'
379
379
  )}
380
380
  >
381
381
  {repo.name}
@@ -413,7 +413,7 @@ function LocalChartCard({ chart, onSelect }: LocalChartCardProps) {
413
413
  <img
414
414
  src={chart.icon}
415
415
  alt=""
416
- className="w-10 h-10 rounded object-contain bg-white/10 p-1"
416
+ className="w-10 h-10 rounded object-contain bg-theme-elevated p-1"
417
417
  onError={(e) => {
418
418
  (e.target as HTMLImageElement).style.display = 'none'
419
419
  }}
@@ -476,7 +476,7 @@ function ArtifactHubChartCard({ chart, onSelect }: ArtifactHubChartCardProps) {
476
476
  <img
477
477
  src={chart.logoUrl}
478
478
  alt=""
479
- className="w-12 h-12 rounded object-contain bg-white/10 p-1 shrink-0"
479
+ className="w-12 h-12 rounded object-contain bg-theme-elevated p-1 shrink-0"
480
480
  onError={(e) => {
481
481
  (e.target as HTMLImageElement).style.display = 'none'
482
482
  }}
@@ -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'
@@ -148,12 +149,10 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
148
149
  }, [])
149
150
 
150
151
  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
- }
152
+ // Swallow the InvalidStateError the API rejects with on rapid
153
+ // tab clicks (SKY-833 bug 49); fall back synchronously when the
154
+ // API isn't available.
155
+ startViewTransitionSafe(() => flushSync(() => setActiveTab(tab)))
157
156
  }, [])
158
157
 
159
158
  const handleCompareRevisions = (rev1: number, rev2: number) => {
@@ -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">
@@ -457,7 +457,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
457
457
  value={searchTerm}
458
458
  onChange={(e) => setSearchTerm(e.target.value)}
459
459
  placeholder="Search... (press /)"
460
- className="w-80 pl-9 pr-8 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
460
+ className="w-80 pl-9 pr-8 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
461
461
  />
462
462
  {searchTerm && (
463
463
  <button
@@ -490,7 +490,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
490
490
  {panOffset > 0 && (
491
491
  <button
492
492
  onClick={() => setPanOffset(0)}
493
- className="px-2 py-1 text-xs text-blue-600 dark:text-blue-300 hover:text-blue-700 dark:hover:text-blue-200 hover:bg-theme-elevated rounded"
493
+ className="px-2 py-1 text-xs text-accent-text hover:underline hover:bg-theme-elevated rounded"
494
494
  title="Jump to current time"
495
495
  >
496
496
  → Now
@@ -519,7 +519,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
519
519
  type="checkbox"
520
520
  checked={groupByApp}
521
521
  onChange={(e) => setGroupByApp(e.target.checked)}
522
- className="w-3.5 h-3.5 rounded border-theme-border-light bg-theme-elevated text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
522
+ className="w-3.5 h-3.5 rounded border-theme-border-light bg-theme-elevated text-accent focus:ring-accent focus:ring-offset-0"
523
523
  />
524
524
  <span className="border-b border-dotted border-theme-text-tertiary">Group by app</span>
525
525
  </label>
@@ -687,7 +687,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
687
687
  <div className="flex items-center gap-1">
688
688
  <span className={clsx(
689
689
  'text-xs px-1 py-0.5 rounded',
690
- lane.isWorkload ? 'bg-blue-500/15 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : 'bg-theme-elevated text-theme-text-secondary'
690
+ lane.isWorkload ? 'bg-accent-muted text-accent-text' : 'bg-theme-elevated text-theme-text-secondary'
691
691
  )}>
692
692
  {displayKind(lane.kind)}
693
693
  </span>
@@ -711,7 +711,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
711
711
  )
712
712
  })()}
713
713
  </div>
714
- <div className="text-sm text-theme-text-primary break-words group-hover:text-blue-600 dark:group-hover:text-blue-300 group-hover:underline cursor-pointer">
714
+ <div className="text-sm text-theme-text-primary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
715
715
  {lane.name}
716
716
  </div>
717
717
  <div className="text-xs text-theme-text-tertiary">{lane.namespace}</div>
@@ -751,7 +751,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
751
751
  {/* Child lanes (when expanded) - includes parent as first row */}
752
752
  {isExpanded && hasChildren && (
753
753
  <div
754
- className="border-l-2 border-blue-500/40 ml-3 bg-theme-surface/30"
754
+ className="border-l-2 border-accent/40 ml-3 bg-theme-surface/30"
755
755
  style={{ animation: 'swimlane-expand 250ms ease-out both' }}
756
756
  >
757
757
  {/* Parent's own events as first row (only if it has events) */}
@@ -764,11 +764,11 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
764
764
  >
765
765
  <div className="flex-1 min-w-0">
766
766
  <div className="flex items-center gap-1">
767
- <span className="text-xs px-1 py-0.5 rounded bg-blue-500/15 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300">
767
+ <span className="text-xs px-1 py-0.5 rounded bg-accent-muted text-accent-text">
768
768
  {displayKind(lane.kind)}
769
769
  </span>
770
770
  </div>
771
- <div className="text-sm text-theme-text-secondary break-words group-hover:text-blue-600 dark:group-hover:text-blue-300 group-hover:underline cursor-pointer">
771
+ <div className="text-sm text-theme-text-secondary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
772
772
  {lane.name}
773
773
  </div>
774
774
  </div>
@@ -820,7 +820,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
820
820
  {displayKind(child.kind)}
821
821
  </span>
822
822
  </div>
823
- <div className="text-sm text-theme-text-secondary break-words group-hover:text-blue-600 dark:group-hover:text-blue-300 group-hover:underline cursor-pointer">
823
+ <div className="text-sm text-theme-text-secondary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
824
824
  {child.name}
825
825
  </div>
826
826
  </div>
@@ -1093,23 +1093,22 @@ function EventMarker({ event, x, selected, onClick, dimmed, small }: EventMarker
1093
1093
  return 'bg-red-500'
1094
1094
  }
1095
1095
 
1096
- // Solid fill for real-time events
1097
- const opacity = dimmed ? '/50' : ''
1098
- // Problematic events (warnings, BackOff, etc.) are always amber/orange
1096
+ // Solid fill for real-time events.
1097
+ // Problematic events (warnings, BackOff, etc.) are always amber/orange.
1099
1098
  if (isProblematic) {
1100
- return `bg-amber-500${opacity}`
1099
+ return dimmed ? 'bg-amber-500/50' : 'bg-amber-500'
1101
1100
  }
1102
1101
  if (isChange) {
1103
1102
  switch (event.eventType) {
1104
1103
  case 'add':
1105
- return `bg-green-500${opacity}`
1104
+ return dimmed ? 'bg-green-500/50' : 'bg-green-500'
1106
1105
  case 'delete':
1107
- return `bg-red-500${opacity}`
1106
+ return dimmed ? 'bg-red-500/50' : 'bg-red-500'
1108
1107
  case 'update':
1109
- return `bg-blue-500${opacity}`
1108
+ return dimmed ? 'bg-blue-500/50' : 'bg-blue-500'
1110
1109
  }
1111
1110
  }
1112
- return `bg-theme-text-tertiary${opacity}`
1111
+ return dimmed ? 'bg-theme-text-tertiary/50' : 'bg-theme-text-tertiary'
1113
1112
  }
1114
1113
 
1115
1114
  const markerClasses = getMarkerStyle()
@@ -1236,7 +1235,7 @@ function EventDetailPanel({ event, onClose, onResourceClick }: EventDetailPanelP
1236
1235
  </span>
1237
1236
  <button
1238
1237
  onClick={() => onResourceClick?.({ kind: kindToPlural(event.kind), namespace: event.namespace, name: event.name })}
1239
- className="text-theme-text-primary font-medium hover:text-blue-600 dark:hover:text-blue-300"
1238
+ className="text-theme-text-primary font-medium hover:text-accent-text"
1240
1239
  >
1241
1240
  {event.name}
1242
1241
  </button>
@@ -4,7 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
5
5
  import { openExternal } from '../../utils/navigation'
6
6
  import { useDiagnostics } from '../../api/client'
7
- import type { DiagnosticsSnapshot, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry } from '../../api/client'
7
+ import type { DiagnosticsSnapshot, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase } from '../../api/client'
8
8
 
9
9
  interface DiagnosticsOverlayProps {
10
10
  onClose: () => void
@@ -315,10 +315,41 @@ function EventPipelineSection({ data }: { data: DiagnosticsSnapshot }) {
315
315
  function InformersSection({ data }: { data: DiagnosticsSnapshot }) {
316
316
  if (!data.informers) return null
317
317
  const inf = data.informers
318
+ const sync = inf.syncStatus
319
+ const phaseWarn = sync ? sync.phase !== 'complete' : false
320
+ const criticalWarn = sync ? sync.criticalSynced < sync.criticalTotal : false
321
+ const promoted = sync?.promotedKinds ?? []
322
+ const pendingCritical = sync?.pendingCritical ?? []
323
+ const pendingDeferred = sync?.pendingDeferred ?? []
324
+ const sectionWarn = phaseWarn || criticalWarn || promoted.length > 0
318
325
  return (
319
- <Section title="Informers">
326
+ <Section title="Informers" warn={sectionWarn}>
320
327
  <Row label="Typed" value={inf.typedCount} />
321
328
  <Row label="Dynamic (CRDs)" value={inf.dynamicCount} />
329
+ {sync && (
330
+ <>
331
+ <Row
332
+ label="Sync Phase"
333
+ value={`${formatSyncPhase(sync.phase)} (${formatElapsed(sync.elapsedSec)})`}
334
+ warn={phaseWarn}
335
+ />
336
+ <Row
337
+ label="Critical Synced"
338
+ value={`${sync.criticalSynced} / ${sync.criticalTotal}`}
339
+ warn={criticalWarn}
340
+ />
341
+ <Row
342
+ label="Deferred Synced"
343
+ value={`${sync.deferredSynced} / ${sync.deferredTotal}`}
344
+ />
345
+ {promoted.length > 0 && (
346
+ <Row label="Promoted to Deferred" value={promoted.join(', ')} warn />
347
+ )}
348
+ {(pendingCritical.length > 0 || pendingDeferred.length > 0) && (
349
+ <PendingInformers sync={sync} />
350
+ )}
351
+ </>
352
+ )}
322
353
  {inf.watchedCRDs && inf.watchedCRDs.length > 0 && (
323
354
  <Row label="Watched CRDs" value={inf.watchedCRDs.join(', ')} />
324
355
  )}
@@ -326,6 +357,53 @@ function InformersSection({ data }: { data: DiagnosticsSnapshot }) {
326
357
  )
327
358
  }
328
359
 
360
+ function PendingInformers({ sync }: { sync: DiagCacheSyncStatus }) {
361
+ const pending = getPendingInformers(sync)
362
+ if (pending.length === 0) return null
363
+ return (
364
+ <div className="mt-1.5 pt-1.5 border-t border-theme-border-light">
365
+ <span className="text-[10px] text-theme-text-tertiary uppercase">Pending Informers ({pending.length})</span>
366
+ {pending.map((i: DiagInformerSyncStatus) => (
367
+ <Row
368
+ key={i.kind}
369
+ label={`${i.kind} (${i.deferred ? 'deferred' : 'critical'})`}
370
+ value={`${i.items.toLocaleString()} items so far`}
371
+ warn={!i.deferred}
372
+ />
373
+ ))}
374
+ </div>
375
+ )
376
+ }
377
+
378
+ function getPendingInformers(sync: DiagCacheSyncStatus): DiagInformerSyncStatus[] {
379
+ const pendingNames = new Set([
380
+ ...(sync.pendingCritical ?? []),
381
+ ...(sync.pendingDeferred ?? []),
382
+ ])
383
+ return sync.informers
384
+ .filter((i) => pendingNames.has(i.kind))
385
+ .sort((a, b) => Number(a.deferred) - Number(b.deferred) || a.kind.localeCompare(b.kind))
386
+ }
387
+
388
+ function formatSyncPhase(phase: DiagSyncPhase): string {
389
+ switch (phase) {
390
+ case 'not_started': return 'not started'
391
+ case 'syncing_critical': return 'syncing critical'
392
+ case 'syncing_deferred': return 'syncing deferred'
393
+ case 'complete': return 'complete'
394
+ }
395
+ }
396
+
397
+ function formatElapsed(sec: number): string {
398
+ const s = Math.max(0, sec)
399
+ if (s < 1) return `${Math.round(s * 1000)}ms`
400
+ if (s < 60) return `${s.toFixed(1)}s`
401
+ const total = Math.round(s)
402
+ const m = Math.floor(total / 60)
403
+ const rem = total - m * 60
404
+ return `${m}m ${rem}s`
405
+ }
406
+
329
407
  function PrometheusSection({ data }: { data: DiagnosticsSnapshot }) {
330
408
  if (!data.prometheus) return null
331
409
  const p = data.prometheus
@@ -512,6 +590,19 @@ function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): stri
512
590
  const inf = data.informers
513
591
  lines.push(`### Informers`)
514
592
  lines.push(`- Typed: ${inf.typedCount} | Dynamic: ${inf.dynamicCount}`)
593
+ if (inf.syncStatus) {
594
+ const sync = inf.syncStatus
595
+ lines.push(`- Sync Phase: \`${sync.phase}\` (${formatElapsed(sync.elapsedSec)})`)
596
+ lines.push(`- Critical: ${sync.criticalSynced}/${sync.criticalTotal} synced | Deferred: ${sync.deferredSynced}/${sync.deferredTotal} synced`)
597
+ if (sync.promotedKinds && sync.promotedKinds.length > 0) {
598
+ lines.push(`- **Promoted to Deferred:** ${sync.promotedKinds.join(', ')}`)
599
+ }
600
+ const pending = getPendingInformers(sync)
601
+ if (pending.length > 0) {
602
+ const parts = pending.map((i) => `${i.kind}(${i.deferred ? 'deferred' : 'critical'},${i.items.toLocaleString()} items)`)
603
+ lines.push(`- **Pending:** ${parts.join(', ')}`)
604
+ }
605
+ }
515
606
  if (inf.watchedCRDs && inf.watchedCRDs.length > 0) {
516
607
  lines.push(`- CRDs: ${inf.watchedCRDs.join(', ')}`)
517
608
  }
@@ -111,9 +111,9 @@ export function UpdateNotification() {
111
111
  const effectiveState: DesktopUpdateState = updateStatus?.state ?? 'idle'
112
112
 
113
113
  return (
114
- <div className="fixed bottom-4 right-4 z-50 max-w-sm bg-theme-surface border border-blue-500/50 rounded-lg shadow-xl p-4 animate-in slide-in-from-right">
114
+ <div className="fixed bottom-4 right-4 z-50 max-w-sm bg-theme-surface border border-accent/50 rounded-lg shadow-xl p-4 animate-in slide-in-from-right">
115
115
  <div className="flex items-start gap-3">
116
- <div className="flex items-center justify-center w-8 h-8 bg-blue-500/20 rounded-full shrink-0">
116
+ <div className="flex items-center justify-center w-8 h-8 bg-accent-muted rounded-full shrink-0">
117
117
  <UpdateIcon state={effectiveState} />
118
118
  </div>
119
119
  <div className="flex-1 min-w-0">
@@ -154,7 +154,7 @@ export function UpdateNotification() {
154
154
  href={versionInfo.releaseUrl}
155
155
  target="_blank"
156
156
  rel="noopener noreferrer"
157
- className="inline-flex items-center gap-1 mt-2 text-xs font-medium text-blue-400 hover:text-blue-300"
157
+ className="inline-flex items-center gap-1 mt-2 text-xs font-medium text-accent-text hover:underline"
158
158
  >
159
159
  Download from GitHub →
160
160
  </a>
@@ -186,11 +186,11 @@ function UpdateIcon({ state }: { state: DesktopUpdateState }) {
186
186
  switch (state) {
187
187
  case 'downloading':
188
188
  case 'applying':
189
- return <Loader2 className="w-4 h-4 text-blue-400 animate-spin" />
189
+ return <Loader2 className="w-4 h-4 text-accent animate-spin" />
190
190
  case 'ready':
191
191
  return <ArrowDownToLine className="w-4 h-4 text-green-400" />
192
192
  default:
193
- return <Download className="w-4 h-4 text-blue-400" />
193
+ return <Download className="w-4 h-4 text-accent" />
194
194
  }
195
195
  }
196
196
 
@@ -247,7 +247,7 @@ function DesktopUpdateControls({
247
247
  <div className="mt-2 space-y-1">
248
248
  <div className="w-full bg-theme-elevated rounded-full h-1.5 overflow-hidden">
249
249
  <div
250
- className="bg-blue-500 h-full rounded-full transition-all duration-300"
250
+ className="bg-accent h-full rounded-full transition-all duration-300"
251
251
  style={{ width: `${Math.round((progress ?? 0) * 100)}%` }}
252
252
  />
253
253
  </div>
@@ -272,7 +272,7 @@ function DesktopUpdateControls({
272
272
  case 'applying':
273
273
  return (
274
274
  <div className="mt-2 flex items-center gap-2">
275
- <Loader2 className="w-3.5 h-3.5 text-blue-400 animate-spin" />
275
+ <Loader2 className="w-3.5 h-3.5 text-accent animate-spin" />
276
276
  <p className="text-xs text-theme-text-secondary">Applying update...</p>
277
277
  </div>
278
278
  )