@skyhook-io/radar-app 0.2.1 → 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 +6 -6
- package/src/App.tsx +9 -3
- package/src/api/client.ts +27 -1
- package/src/assets/platform-icons/aws_eks.png +0 -0
- package/src/assets/platform-icons/azure-aks.svg +2 -0
- package/src/assets/platform-icons/google_kubernetes_engine.png +0 -0
- package/src/components/audit/AuditSettingsDialog.tsx +49 -10
- package/src/components/helm/ChartBrowser.tsx +11 -11
- package/src/components/helm/HelmReleaseDrawer.tsx +5 -6
- package/src/components/helm/InstallWizard.tsx +79 -22
- package/src/components/home/ClusterHealthCard.tsx +70 -20
- package/src/components/home/HomeView.tsx +13 -1
- package/src/components/portforward/PortForwardButton.tsx +37 -16
- 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/contexts/CapabilitiesContext.tsx +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@monaco-editor/react": "^4.7.0",
|
|
33
33
|
"diff": "^9.0.0",
|
|
34
34
|
"react-markdown": "^10.1.0",
|
|
35
|
-
"react-virtuoso": "^4.18.
|
|
35
|
+
"react-virtuoso": "^4.18.6",
|
|
36
36
|
"remark-gfm": "^4.0.1",
|
|
37
37
|
"shiki": "^4.0.1",
|
|
38
38
|
"yaml": "^2.8.3"
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@skyhook-io/k8s-ui": "*",
|
|
55
55
|
"@tailwindcss/typography": "^0.5.19",
|
|
56
56
|
"@tailwindcss/vite": "^4.2.4",
|
|
57
|
-
"@tanstack/react-query": "^5.
|
|
57
|
+
"@tanstack/react-query": "^5.100.6",
|
|
58
58
|
"@types/diff": "^8.0.0",
|
|
59
59
|
"@types/node": "^25.5.0",
|
|
60
60
|
"@types/react": "^19.2.14",
|
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
"@xyflow/react": "^12.10.2",
|
|
64
64
|
"clsx": "^2.1.1",
|
|
65
65
|
"elkjs": "^0.11.1",
|
|
66
|
-
"lucide-react": "^1.
|
|
67
|
-
"postcss": "^8.5.
|
|
66
|
+
"lucide-react": "^1.12.0",
|
|
67
|
+
"postcss": "^8.5.12",
|
|
68
68
|
"prettier": "^3.8.1",
|
|
69
69
|
"react": "^19.2.5",
|
|
70
70
|
"react-dom": "^19.2.5",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"tailwind-merge": "^3.5.0",
|
|
73
73
|
"tailwindcss": "^4.2.4",
|
|
74
74
|
"typescript": "^6.0.2",
|
|
75
|
-
"vite": "^8.0.
|
|
75
|
+
"vite": "^8.0.10"
|
|
76
76
|
},
|
|
77
77
|
"sideEffects": [
|
|
78
78
|
"*.css"
|
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
|
-
|
|
349
|
-
|
|
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
|
}
|
|
@@ -366,7 +372,7 @@ function AppInner() {
|
|
|
366
372
|
const switchContext = useSwitchContext()
|
|
367
373
|
|
|
368
374
|
// View switching keyboard shortcuts
|
|
369
|
-
const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'traffic']
|
|
375
|
+
const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'traffic', 'cost', 'audit']
|
|
370
376
|
useRegisterShortcuts([
|
|
371
377
|
...views.map((view, i) => ({
|
|
372
378
|
id: `view-${view}`,
|
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[] //
|
|
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
|
|
Binary file
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
|
2
|
+
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="url(#azure-aks-color-16__paint0_linear_2372_185)" d="M5.511 2l-2.224.412v3.034l2.224.474 2.232-.894V2.762L5.511 2z"/><path fill="#341A6E" d="M3.287 2.412v3.034l2.247.474V2.031l-2.247.381zm.949 2.8l-.63-.124V2.754l.63-.1v2.558zm.98.18l-.724-.118V2.607l.724-.125v2.91z"/><path fill="url(#azure-aks-color-16__paint1_linear_2372_185)" d="M10.325 2.039l-2.224.412v3.033l2.224.475 2.225-.902V2.8l-2.225-.762z"/><path fill="#341A6E" d="M8.101 2.451v3.033l2.232.475V2.07l-2.232.381zm.941 2.8l-.63-.124V2.793l.63-.1V5.25zm.98.179L9.3 5.313V2.646l.723-.133V5.43z"/><path fill="url(#azure-aks-color-16__paint2_linear_2372_185)" d="M3.232 6.184l-2.224.413V9.63l2.224.474 2.232-.894V6.947l-2.232-.763z"/><path fill="#341A6E" d="M1 6.597v3.01l2.248.474V6.192L1 6.597zm.941 2.807l-.63-.132V6.94l.63-.109v2.574zm.988.203l-.723-.117V6.791l.723-.124v2.94z"/><path fill="url(#azure-aks-color-16__paint3_linear_2372_185)" d="M8.031 6.153l-2.224.413v3.033l2.224.482 2.224-.902V6.916l-2.224-.763z"/><path fill="#341A6E" d="M5.807 6.566v3.04l2.24.475V6.192l-2.24.374zm.94 2.807l-.63-.132V6.908l.63-.11v2.575zm.98.171l-.723-.116V6.76l.724-.124v2.908z"/><path fill="url(#azure-aks-color-16__paint4_linear_2372_185)" d="M12.83 6.192l-2.225.412v3.034l2.225.474 2.232-.894V6.954l-2.232-.762z"/><path fill="#341A6E" d="M10.605 6.604v3.003l2.248.474V6.192l-2.248.412zm.95 2.808l-.63-.132V6.947l.63-.11v2.575zm.98.171l-.724-.116V6.799l.723-.125v2.91z"/><path fill="url(#azure-aks-color-16__paint5_linear_2372_185)" d="M5.457 10.415l-2.225.405v3.033l2.225.482 2.232-.902v-2.255l-2.232-.763z"/><path fill="#341A6E" d="M3.232 10.82v3.033l2.248.483v-3.952l-2.248.436zm.95 2.808l-.63-.132v-2.334l.63-.109v2.575zm.98.179l-.724-.117v-2.675l.723-.125v2.917z"/><path fill="url(#azure-aks-color-16__paint6_linear_2372_185)" d="M10.263 10.447l-2.224.412v3.033l2.224.475 2.232-.895V11.21l-2.232-.762z"/><path fill="#341A6E" d="M8.039 10.859v3.033l2.248.475v-3.89l-2.248.382zm.949 2.808l-.63-.133v-2.333l.63-.109v2.575zm.98.17l-.724-.116v-2.668l.724-.124v2.909z"/><defs><linearGradient id="azure-aks-color-16__paint0_linear_2372_185" x1="3.287" x2="7.743" y1="3.96" y2="3.96" gradientUnits="userSpaceOnUse"><stop stop-color="#B77AF4"/><stop offset="1" stop-color="#773ADC"/></linearGradient><linearGradient id="azure-aks-color-16__paint1_linear_2372_185" x1="8.101" x2="12.55" y1="3.999" y2="3.999" gradientUnits="userSpaceOnUse"><stop stop-color="#B77AF4"/><stop offset="1" stop-color="#773ADC"/></linearGradient><linearGradient id="azure-aks-color-16__paint2_linear_2372_185" x1="1.008" x2="5.457" y1="8.144" y2="8.144" gradientUnits="userSpaceOnUse"><stop stop-color="#B77AF4"/><stop offset="1" stop-color="#773ADC"/></linearGradient><linearGradient id="azure-aks-color-16__paint3_linear_2372_185" x1="5.807" x2="10.255" y1="8.113" y2="8.113" gradientUnits="userSpaceOnUse"><stop stop-color="#B77AF4"/><stop offset="1" stop-color="#773ADC"/></linearGradient><linearGradient id="azure-aks-color-16__paint4_linear_2372_185" x1="10.605" x2="15.062" y1="8.152" y2="8.152" gradientUnits="userSpaceOnUse"><stop stop-color="#B77AF4"/><stop offset="1" stop-color="#773ADC"/></linearGradient><linearGradient id="azure-aks-color-16__paint5_linear_2372_185" x1="3.232" x2="7.689" y1="12.376" y2="12.376" gradientUnits="userSpaceOnUse"><stop stop-color="#B77AF4"/><stop offset="1" stop-color="#773ADC"/></linearGradient><linearGradient id="azure-aks-color-16__paint6_linear_2372_185" x1="8.039" x2="12.495" y1="12.407" y2="12.407" gradientUnits="userSpaceOnUse"><stop stop-color="#B77AF4"/><stop offset="1" stop-color="#773ADC"/></linearGradient></defs></svg>
|
|
Binary file
|
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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={!
|
|
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
|
-
|
|
154
|
-
|
|
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-
|
|
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-
|
|
189
|
+
className="rounded border-theme-border text-accent focus:ring-accent"
|
|
190
190
|
/>
|
|
191
|
-
<BadgeCheck className="w-3.5 h-3.5 text-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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(
|
|
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
|
)}
|
|
@@ -12,6 +12,9 @@ import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
|
|
|
12
12
|
import { MCPSetupDialog } from './MCPSetupDialog'
|
|
13
13
|
import { pluralize, parseContextName } from '@skyhook-io/k8s-ui'
|
|
14
14
|
import { Tooltip } from '../ui/Tooltip'
|
|
15
|
+
import gkeIcon from '../../assets/platform-icons/google_kubernetes_engine.png'
|
|
16
|
+
import eksIcon from '../../assets/platform-icons/aws_eks.png'
|
|
17
|
+
import aksIcon from '../../assets/platform-icons/azure-aks.svg'
|
|
15
18
|
|
|
16
19
|
interface ClusterHealthCardProps {
|
|
17
20
|
health: DashboardResponse['health']
|
|
@@ -67,13 +70,13 @@ function MetricsUnavailableHint({ platform, metricsServerAvailable }: { platform
|
|
|
67
70
|
function getPlatformInfo(platform: string): { name: string; icon: string | null } {
|
|
68
71
|
const platformLower = platform.toLowerCase()
|
|
69
72
|
if (platformLower.includes('gke') || platformLower.includes('google')) {
|
|
70
|
-
return { name: 'Google Kubernetes Engine', icon:
|
|
73
|
+
return { name: 'Google Kubernetes Engine', icon: gkeIcon }
|
|
71
74
|
}
|
|
72
75
|
if (platformLower.includes('eks') || platformLower.includes('amazon') || platformLower.includes('aws')) {
|
|
73
|
-
return { name: 'Amazon EKS', icon:
|
|
76
|
+
return { name: 'Amazon EKS', icon: eksIcon }
|
|
74
77
|
}
|
|
75
78
|
if (platformLower.includes('aks') || platformLower.includes('azure')) {
|
|
76
|
-
return { name: 'Azure Kubernetes Service', icon:
|
|
79
|
+
return { name: 'Azure Kubernetes Service', icon: aksIcon }
|
|
77
80
|
}
|
|
78
81
|
if (platformLower.includes('openshift')) {
|
|
79
82
|
return { name: 'OpenShift', icon: null }
|
|
@@ -93,7 +96,14 @@ function getPlatformInfo(platform: string): { name: string; icon: string | null
|
|
|
93
96
|
if (platformLower.includes('docker')) {
|
|
94
97
|
return { name: 'Docker Desktop', icon: null }
|
|
95
98
|
}
|
|
96
|
-
|
|
99
|
+
// The Go backend returns "generic" for unrecognized platforms — that
|
|
100
|
+
// literal string is no better than the empty case, so fall back to
|
|
101
|
+
// the friendlier "Kubernetes" label for both. Only pass the platform
|
|
102
|
+
// string through when it's actually a recognizable name.
|
|
103
|
+
if (!platform || platformLower === 'generic') {
|
|
104
|
+
return { name: 'Kubernetes', icon: null }
|
|
105
|
+
}
|
|
106
|
+
return { name: platform, icon: null }
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
export function ClusterHealthCard({
|
|
@@ -113,8 +123,20 @@ export function ClusterHealthCard({
|
|
|
113
123
|
void _topCRDs // Reserved for future CRD display
|
|
114
124
|
|
|
115
125
|
const [mcpDialogOpen, setMcpDialogOpen] = useState(false)
|
|
116
|
-
const
|
|
126
|
+
const caps = useCapabilitiesContext()
|
|
127
|
+
// Default to local mode when the backend doesn't ship a `deployment`
|
|
128
|
+
// field (older Radar binaries pre-0.2.2). Local rendering is the safe
|
|
129
|
+
// OSS-shape default — wrong-direction defaults would briefly suppress
|
|
130
|
+
// chrome OSS users expect to see.
|
|
131
|
+
const deployment = caps.deployment ?? { mode: 'local' as const }
|
|
132
|
+
const mcpEnabled = caps.mcpEnabled
|
|
133
|
+
const isCloud = deployment.mode === 'cloud'
|
|
134
|
+
const isInCluster = deployment.mode === 'in-cluster' || deployment.mode === 'cloud'
|
|
117
135
|
const mcpUrl = `${window.location.origin}/mcp`
|
|
136
|
+
// In Cloud, MCP is org-wide and PAT-authed (api.radarhq.io/mcp). The OSS
|
|
137
|
+
// "this binary is your local MCP server" framing is wrong there — Cloud
|
|
138
|
+
// surfaces MCP from the hub Home dashboard instead.
|
|
139
|
+
const showLocalMcpCard = mcpEnabled && !isCloud
|
|
118
140
|
|
|
119
141
|
const restricted = counts.restricted ?? []
|
|
120
142
|
const isRestricted = (kind: string) => restricted.includes(kind)
|
|
@@ -158,13 +180,23 @@ export function ClusterHealthCard({
|
|
|
158
180
|
{ kind: 'cronjobs', label: 'CronJobs', icon: Clock, total: counts.cronJobs.total, subtitle: `${counts.cronJobs.active} active` },
|
|
159
181
|
]
|
|
160
182
|
const platformInfo = getPlatformInfo(cluster.platform)
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
183
|
+
// Headline-name derivation has three branches, in priority order:
|
|
184
|
+
// 1. Local-kubeconfig users get the parsed short clusterName from a
|
|
185
|
+
// string like `gke_koalabackend_us-east1-b_nonprod-cluster-us-east1`
|
|
186
|
+
// (the meaningful tail). Account/region are surfaced separately
|
|
187
|
+
// below as muted metadata, and the raw path is exposed via tooltip
|
|
188
|
+
// on the headline element.
|
|
189
|
+
// 2. In-cluster mode (deployment.mode === 'in-cluster' OR 'cloud')
|
|
190
|
+
// has no meaningful kubeconfig context — bootstrap sets it to
|
|
191
|
+
// the literal "in-cluster" sentinel. Fall back to the platform
|
|
192
|
+
// label ("Google Kubernetes Engine") which IS recognizable.
|
|
193
|
+
// 3. Last resort: the literal cluster.name, or "Cluster".
|
|
194
|
+
// When the card is rendered embedded (cloud mode), the H2 itself is
|
|
195
|
+
// suppressed below — the hub shell already shows the cluster name in
|
|
196
|
+
// its top bar.
|
|
166
197
|
const parsedContext = parseContextName(cluster.name || '')
|
|
167
|
-
const
|
|
198
|
+
const rawHeadline = parsedContext.clusterName || cluster.name || 'Cluster'
|
|
199
|
+
const headlineName = isInCluster ? platformInfo.name : rawHeadline
|
|
168
200
|
|
|
169
201
|
return (
|
|
170
202
|
<div className="rounded-xl bg-theme-surface shadow-theme-sm overflow-hidden">
|
|
@@ -181,12 +213,23 @@ export function ClusterHealthCard({
|
|
|
181
213
|
)}
|
|
182
214
|
<span className="text-xs text-theme-text-secondary truncate">{platformInfo.name}</span>
|
|
183
215
|
</div>
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
216
|
+
{/* In Cloud, the hub shell already shows the cluster name in
|
|
217
|
+
its top bar; rendering it again here is redundant and
|
|
218
|
+
makes the card feel like a label rather than content. */}
|
|
219
|
+
{!isCloud && (
|
|
220
|
+
<h2
|
|
221
|
+
className="text-xl font-semibold text-theme-text-primary truncate mb-1.5 leading-tight"
|
|
222
|
+
// In-cluster mode's cluster.name is the literal "in-cluster"
|
|
223
|
+
// sentinel, which would leak via the browser hover tooltip
|
|
224
|
+
// even though the visible text falls back to the platform
|
|
225
|
+
// label. Drop the title attribute entirely in that case;
|
|
226
|
+
// local mode keeps it so users can hover to see the full
|
|
227
|
+
// kubeconfig context path.
|
|
228
|
+
title={isInCluster ? undefined : cluster.name}
|
|
229
|
+
>
|
|
230
|
+
{headlineName}
|
|
231
|
+
</h2>
|
|
232
|
+
)}
|
|
190
233
|
<div className="flex flex-col gap-0.5 text-xs text-theme-text-tertiary">
|
|
191
234
|
{(parsedContext.account || parsedContext.region) && (
|
|
192
235
|
<span className="truncate font-mono" title={[parsedContext.account, parsedContext.region].filter(Boolean).join(' · ')}>
|
|
@@ -197,7 +240,11 @@ export function ClusterHealthCard({
|
|
|
197
240
|
<span>Kubernetes {cluster.version}</span>
|
|
198
241
|
)}
|
|
199
242
|
<span><span className="font-mono">{counts.namespaces}</span> namespaces</span>
|
|
200
|
-
{
|
|
243
|
+
{/* Show raw kubeconfig context as muted metadata only when
|
|
244
|
+
it differs from the headline AND we're in local mode
|
|
245
|
+
(in-cluster has no meaningful context name, cloud
|
|
246
|
+
shell already renders the canonical name). */}
|
|
247
|
+
{cluster.name && cluster.name !== headlineName && deployment.mode === 'local' && (
|
|
201
248
|
<span
|
|
202
249
|
className="font-mono text-[10px] text-theme-text-disabled break-all leading-snug pt-0.5"
|
|
203
250
|
title={cluster.name}
|
|
@@ -229,8 +276,11 @@ export function ClusterHealthCard({
|
|
|
229
276
|
</span>
|
|
230
277
|
</Tooltip>
|
|
231
278
|
)}
|
|
232
|
-
{/* MCP Server indicator
|
|
233
|
-
|
|
279
|
+
{/* MCP Server indicator. OSS-only: in Cloud, MCP discovery
|
|
280
|
+
lives at the hub level (org-wide endpoint, PAT-authed)
|
|
281
|
+
rather than per-cluster, so this localhost/no-auth card
|
|
282
|
+
would mislead a Cloud user. */}
|
|
283
|
+
{showLocalMcpCard && (
|
|
234
284
|
<button
|
|
235
285
|
onClick={() => setMcpDialogOpen(true)}
|
|
236
286
|
className="flex items-center gap-2 mt-3 px-2.5 py-2 bg-purple-500/5 hover:bg-purple-500/10 border border-purple-500/20 rounded-md transition-colors w-full"
|
|
@@ -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">
|
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
1104
|
+
return dimmed ? 'bg-green-500/50' : 'bg-green-500'
|
|
1106
1105
|
case 'delete':
|
|
1107
|
-
return
|
|
1106
|
+
return dimmed ? 'bg-red-500/50' : 'bg-red-500'
|
|
1108
1107
|
case 'update':
|
|
1109
|
-
return
|
|
1108
|
+
return dimmed ? 'bg-blue-500/50' : 'bg-blue-500'
|
|
1110
1109
|
}
|
|
1111
1110
|
}
|
|
1112
|
-
return
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
)
|
|
@@ -13,6 +13,11 @@ const defaultCapabilities: Capabilities = {
|
|
|
13
13
|
helmWrite: true,
|
|
14
14
|
nodeWrite: true,
|
|
15
15
|
mcpEnabled: true,
|
|
16
|
+
// Default to 'local' for the loading window so the UI renders the
|
|
17
|
+
// OSS standalone shape until /api/capabilities resolves. Both
|
|
18
|
+
// alternatives ('in-cluster', 'cloud') would cause OSS users to
|
|
19
|
+
// briefly see suppressed chrome — wrong default direction.
|
|
20
|
+
deployment: { mode: 'local' },
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
// Restricted capabilities for error/failure cases (fail-closed)
|
|
@@ -26,6 +31,7 @@ const restrictedCapabilities: Capabilities = {
|
|
|
26
31
|
helmWrite: false,
|
|
27
32
|
nodeWrite: false,
|
|
28
33
|
mcpEnabled: false,
|
|
34
|
+
deployment: { mode: 'local' },
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
const CapabilitiesContext = createContext<Capabilities>(defaultCapabilities)
|