@skyhook-io/radar-app 1.8.2 → 1.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.tsx +412 -146
- package/src/RadarApp.tsx +21 -1
- package/src/api/client.ts +144 -12
- package/src/components/ConnectionErrorView.tsx +1 -1
- package/src/components/ContextSwitcher.tsx +5 -1
- package/src/components/NamespaceSwitcher.tsx +21 -278
- package/src/components/applications/ApplicationsView.tsx +13 -1
- package/src/components/audit/AuditView.tsx +11 -2
- package/src/components/cost/CostView.tsx +12 -2
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/gitops/GitOpsView.tsx +23 -17
- package/src/components/helm/HelmCompareRoute.tsx +1342 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +448 -67
- package/src/components/helm/HelmView.tsx +79 -62
- package/src/components/helm/ManifestDiffViewer.tsx +18 -7
- package/src/components/helm/OwnedResources.tsx +14 -50
- package/src/components/helm/RevisionHistory.tsx +9 -5
- package/src/components/helm/ValuesViewer.tsx +41 -11
- package/src/components/home/ClusterHealthCard.tsx +6 -1
- package/src/components/home/HomeView.tsx +12 -1
- package/src/components/home/mcpToolCatalog.ts +8 -8
- package/src/components/issues/IssuesPane.tsx +29 -18
- package/src/components/portforward/PortForwardButton.tsx +69 -25
- package/src/components/portforward/PortForwardManager.tsx +18 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
- package/src/components/resources/ResourcesView.tsx +45 -1
- package/src/components/resources/renderers/PodRenderer.tsx +7 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
- package/src/components/timeline/TimelineView.tsx +26 -2
- package/src/components/traffic/TrafficView.tsx +17 -10
- package/src/components/ui/Markdown.tsx +2 -2
- package/src/components/ui/Omnibar.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +5 -10
- package/src/components/workload/WorkloadView.tsx +57 -8
- package/src/contexts/CapabilitiesContext.tsx +8 -0
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/index.ts +15 -0
- package/src/main.tsx +5 -3
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +5 -3
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { useState, useMemo, useRef, useEffect, useCallback, forwardRef } from 'react'
|
|
2
|
-
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
3
2
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
4
|
-
import { Package, Search,
|
|
5
|
-
import { PaneLoader, PageHeader } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { Package, Search, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight, RotateCcw, Clock } from 'lucide-react'
|
|
4
|
+
import { PaneLoader, PageHeader, SortableTh, FreshnessControl, type SortDir } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
6
6
|
import { clsx } from 'clsx'
|
|
7
7
|
import { useHelmReleases, useHelmBatchUpgradeInfo, isForbiddenError } from '../../api/client'
|
|
8
8
|
import type { HelmOperation, HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
|
|
9
9
|
import { getStatusColor, formatAge, isHelmReleaseActionable } from './helm-utils'
|
|
10
|
-
import { SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
10
|
+
import { SEVERITY_BADGE, SEVERITY_DOT, SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
11
11
|
import { Tooltip } from '../ui/Tooltip'
|
|
12
12
|
import { ChartBrowser } from './ChartBrowser'
|
|
13
13
|
import { InstallWizard } from './InstallWizard'
|
|
@@ -25,7 +25,7 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
25
25
|
const [searchTerm, setSearchTerm] = useState('')
|
|
26
26
|
const [selectedChart, setSelectedChart] = useState<{ repo: string; chart: string; version: string; source: ChartSource } | null>(null)
|
|
27
27
|
|
|
28
|
-
const { data: releases, isLoading, error: releasesError, refetch: refetchReleases } = useHelmReleases(namespaces)
|
|
28
|
+
const { data: releases, isLoading, error: releasesError, dataUpdatedAt: releasesUpdatedAt, isFetching: releasesFetching, refetch: refetchReleases } = useHelmReleases(namespaces)
|
|
29
29
|
const isForbidden = isForbiddenError(releasesError)
|
|
30
30
|
const releasesErrorMessage = releasesError instanceof Error ? releasesError.message : 'Failed to load Helm releases'
|
|
31
31
|
|
|
@@ -36,24 +36,38 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
36
36
|
)
|
|
37
37
|
const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade checks failed'
|
|
38
38
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
const isFullyLoaded = !isLoading && !upgradeLoading
|
|
39
|
+
const { connection } = useConnection()
|
|
40
|
+
const refetchAll = () => Promise.all([refetchReleases(), refetchUpgradeInfo()])
|
|
44
41
|
|
|
45
42
|
// Filter releases by search term
|
|
43
|
+
// Resources-table cycle: asc → desc → off (null restores the server's order).
|
|
44
|
+
const [sort, setSort] = useState<{ key: HelmSortKey; dir: SortDir } | null>(null)
|
|
45
|
+
const onSort = useCallback(
|
|
46
|
+
(key: HelmSortKey) =>
|
|
47
|
+
setSort((prev) => {
|
|
48
|
+
if (!prev || prev.key !== key) return { key, dir: 'asc' }
|
|
49
|
+
if (prev.dir === 'asc') return { key, dir: 'desc' }
|
|
50
|
+
return null
|
|
51
|
+
}),
|
|
52
|
+
[],
|
|
53
|
+
)
|
|
54
|
+
|
|
46
55
|
const filteredReleases = useMemo(() => {
|
|
47
56
|
if (!releases) return []
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
const term = searchTerm.trim().toLowerCase()
|
|
58
|
+
const filtered = term
|
|
59
|
+
? releases.filter(
|
|
60
|
+
(r) =>
|
|
61
|
+
r.name.toLowerCase().includes(term) ||
|
|
62
|
+
r.namespace.toLowerCase().includes(term) ||
|
|
63
|
+
r.chart.toLowerCase().includes(term)
|
|
64
|
+
)
|
|
65
|
+
: releases
|
|
66
|
+
|
|
67
|
+
if (!sort) return filtered
|
|
68
|
+
const factor = sort.dir === 'asc' ? 1 : -1
|
|
69
|
+
return [...filtered].sort((a, b) => compareReleases(a, b, sort.key) * factor)
|
|
70
|
+
}, [releases, searchTerm, sort])
|
|
57
71
|
|
|
58
72
|
// Keyboard navigation state
|
|
59
73
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
@@ -62,8 +76,9 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
62
76
|
const filteredReleasesCountRef = useRef(0)
|
|
63
77
|
filteredReleasesCountRef.current = filteredReleases.length
|
|
64
78
|
|
|
65
|
-
// Reset highlight when search
|
|
66
|
-
|
|
79
|
+
// Reset highlight when the visible order changes (search or sort) — otherwise
|
|
80
|
+
// the index would point at whatever release shifted into that row.
|
|
81
|
+
useEffect(() => { setHighlightedIndex(-1) }, [searchTerm, sort])
|
|
67
82
|
|
|
68
83
|
// Scroll highlighted row into view
|
|
69
84
|
useEffect(() => {
|
|
@@ -173,6 +188,17 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
173
188
|
icon={Package}
|
|
174
189
|
title="Helm"
|
|
175
190
|
description="Installed Helm releases and the chart catalog for this cluster."
|
|
191
|
+
actions={
|
|
192
|
+
activeTab === 'releases' ? (
|
|
193
|
+
<FreshnessControl
|
|
194
|
+
mode="snapshot"
|
|
195
|
+
dataUpdatedAt={releasesUpdatedAt}
|
|
196
|
+
isFetching={releasesFetching || upgradeLoading}
|
|
197
|
+
onRefresh={refetchAll}
|
|
198
|
+
connectionState={connection.state}
|
|
199
|
+
/>
|
|
200
|
+
) : undefined
|
|
201
|
+
}
|
|
176
202
|
/>
|
|
177
203
|
</div>
|
|
178
204
|
{/* Tab bar */}
|
|
@@ -212,9 +238,6 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
212
238
|
<>
|
|
213
239
|
{/* Releases Toolbar */}
|
|
214
240
|
<div className="flex items-center gap-4 px-4 py-3 border-b border-theme-border bg-theme-surface/50 shrink-0">
|
|
215
|
-
{!isFullyLoaded && (
|
|
216
|
-
<RefreshCw className="w-3.5 h-3.5 animate-spin text-theme-text-tertiary shrink-0" />
|
|
217
|
-
)}
|
|
218
241
|
<div className="flex-1 relative">
|
|
219
242
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
220
243
|
<input
|
|
@@ -226,15 +249,6 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
226
249
|
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"
|
|
227
250
|
/>
|
|
228
251
|
</div>
|
|
229
|
-
<Tooltip content="Refresh">
|
|
230
|
-
<button
|
|
231
|
-
onClick={handleRefresh}
|
|
232
|
-
disabled={isRefreshAnimating}
|
|
233
|
-
className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg disabled:opacity-50 disabled:pointer-events-none"
|
|
234
|
-
>
|
|
235
|
-
<RefreshCw className={clsx('w-4 h-4', isRefreshAnimating && 'animate-spin')} />
|
|
236
|
-
</button>
|
|
237
|
-
</Tooltip>
|
|
238
252
|
</div>
|
|
239
253
|
|
|
240
254
|
{/* Releases Table */}
|
|
@@ -289,29 +303,15 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
289
303
|
</div>
|
|
290
304
|
) : (
|
|
291
305
|
<table className="w-full table-fixed">
|
|
292
|
-
<thead className="bg-theme-
|
|
306
|
+
<thead className="bg-theme-base sticky top-0 z-10">
|
|
293
307
|
<tr>
|
|
294
|
-
<
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
<
|
|
301
|
-
Chart
|
|
302
|
-
</th>
|
|
303
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-24 hidden xl:table-cell">
|
|
304
|
-
App Version
|
|
305
|
-
</th>
|
|
306
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-40">
|
|
307
|
-
Status
|
|
308
|
-
</th>
|
|
309
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-16">
|
|
310
|
-
Rev
|
|
311
|
-
</th>
|
|
312
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-24">
|
|
313
|
-
Updated
|
|
314
|
-
</th>
|
|
308
|
+
<SortableTh label="Name" sortKey="name" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[28%]" />
|
|
309
|
+
<SortableTh label="Namespace" sortKey="namespace" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[18%]" />
|
|
310
|
+
<SortableTh label="Chart" sortKey="chart" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[22%]" />
|
|
311
|
+
<SortableTh label="App Version" sortKey="appVersion" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-24 hidden xl:table-cell" />
|
|
312
|
+
<SortableTh label="Status" sortKey="status" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-40" />
|
|
313
|
+
<SortableTh label="Rev" sortKey="revision" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-16" />
|
|
314
|
+
<SortableTh label="Updated" sortKey="updated" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-24" />
|
|
315
315
|
</tr>
|
|
316
316
|
</thead>
|
|
317
317
|
<tbody className="table-divide-subtle">
|
|
@@ -360,6 +360,23 @@ function releaseIdentityKey(release: Pick<HelmRelease, 'namespace' | 'name' | 's
|
|
|
360
360
|
return `${release.storageNamespace || release.namespace}/${release.name}`
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
type HelmSortKey = 'name' | 'namespace' | 'chart' | 'appVersion' | 'status' | 'revision' | 'updated'
|
|
364
|
+
|
|
365
|
+
function compareReleases(a: HelmRelease, b: HelmRelease, key: HelmSortKey): number {
|
|
366
|
+
let cmp: number
|
|
367
|
+
switch (key) {
|
|
368
|
+
case 'revision':
|
|
369
|
+
cmp = a.revision - b.revision
|
|
370
|
+
break
|
|
371
|
+
case 'updated':
|
|
372
|
+
cmp = (Date.parse(a.updated) || 0) - (Date.parse(b.updated) || 0)
|
|
373
|
+
break
|
|
374
|
+
default:
|
|
375
|
+
cmp = String(a[key] ?? '').localeCompare(String(b[key] ?? ''))
|
|
376
|
+
}
|
|
377
|
+
return cmp || a.name.localeCompare(b.name)
|
|
378
|
+
}
|
|
379
|
+
|
|
363
380
|
interface ReleaseRowProps {
|
|
364
381
|
release: HelmRelease
|
|
365
382
|
upgradeInfo?: UpgradeInfo
|
|
@@ -392,7 +409,7 @@ function getActionableTooltip(issue: string | undefined, summary: string | undef
|
|
|
392
409
|
<div className="max-w-xs">
|
|
393
410
|
<div className={clsx(
|
|
394
411
|
'font-medium',
|
|
395
|
-
health === 'unhealthy' ?
|
|
412
|
+
health === 'unhealthy' ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning
|
|
396
413
|
)}>
|
|
397
414
|
{summary || issue || health}
|
|
398
415
|
</div>
|
|
@@ -483,10 +500,10 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
|
|
|
483
500
|
const getHealthBadge = () => {
|
|
484
501
|
if (!release.resourceHealth || release.resourceHealth === 'unknown') return null
|
|
485
502
|
|
|
486
|
-
const healthStyles: Record<string, {
|
|
487
|
-
healthy: {
|
|
488
|
-
degraded: {
|
|
489
|
-
unhealthy: {
|
|
503
|
+
const healthStyles: Record<string, { badge: string; dot: string }> = {
|
|
504
|
+
healthy: { badge: SEVERITY_BADGE.success, dot: SEVERITY_DOT.success },
|
|
505
|
+
degraded: { badge: SEVERITY_BADGE.warning, dot: SEVERITY_DOT.warning },
|
|
506
|
+
unhealthy: { badge: SEVERITY_BADGE.error, dot: SEVERITY_DOT.error },
|
|
490
507
|
}
|
|
491
508
|
|
|
492
509
|
const style = healthStyles[release.resourceHealth] || healthStyles.healthy
|
|
@@ -495,8 +512,8 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
|
|
|
495
512
|
return (
|
|
496
513
|
<Tooltip content={tooltipContent}>
|
|
497
514
|
<span className={clsx(
|
|
498
|
-
'
|
|
499
|
-
style.
|
|
515
|
+
'badge-sm shrink-0',
|
|
516
|
+
style.badge
|
|
500
517
|
)}>
|
|
501
518
|
<span className={clsx('w-1.5 h-1.5 rounded-full', style.dot)} />
|
|
502
519
|
{release.healthIssue || (release.resourceHealth !== 'healthy' ? release.healthSummary : null)}
|
|
@@ -8,19 +8,21 @@ interface ManifestDiffViewerProps {
|
|
|
8
8
|
revision1: number
|
|
9
9
|
revision2: number
|
|
10
10
|
onClose: () => void
|
|
11
|
+
title?: string
|
|
12
|
+
emptyLabel?: string
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onClose }: ManifestDiffViewerProps) {
|
|
15
|
+
export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onClose, title, emptyLabel }: ManifestDiffViewerProps) {
|
|
14
16
|
if (isLoading) {
|
|
15
17
|
return <PaneLoader label="Computing diff…" className="h-32" />
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
if (!diff) {
|
|
20
|
+
if (!hasDiffBodyChange(diff)) {
|
|
19
21
|
return (
|
|
20
22
|
<div className="p-4">
|
|
21
23
|
<div className="flex flex-col items-center justify-center h-32 text-theme-text-tertiary gap-2">
|
|
22
24
|
<GitCompare className="w-8 h-8 text-theme-text-disabled" />
|
|
23
|
-
<span>No differences found</span>
|
|
25
|
+
<span>{emptyLabel || 'No differences found'}</span>
|
|
24
26
|
</div>
|
|
25
27
|
</div>
|
|
26
28
|
)
|
|
@@ -32,7 +34,7 @@ export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onCl
|
|
|
32
34
|
<div className="flex items-center gap-2">
|
|
33
35
|
<GitCompare className="w-4 h-4 text-theme-text-secondary" />
|
|
34
36
|
<span className="text-sm font-medium text-theme-text-secondary">
|
|
35
|
-
Comparing Revision {revision1} → {revision2}
|
|
37
|
+
{title || `Comparing Revision ${revision1} → ${revision2}`}
|
|
36
38
|
</span>
|
|
37
39
|
</div>
|
|
38
40
|
<button
|
|
@@ -67,7 +69,16 @@ export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onCl
|
|
|
67
69
|
)
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
function
|
|
72
|
+
export function hasDiffBodyChange(diff: string): boolean {
|
|
73
|
+
return diff.split('\n').some((line) => {
|
|
74
|
+
if (!line || line.startsWith('---') || line.startsWith('+++') || line.startsWith('@@')) {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
return line.startsWith('+') || line.startsWith('-')
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function DiffLine({ line }: { line: string }) {
|
|
71
82
|
const isAddition = line.startsWith('+') && !line.startsWith('+++')
|
|
72
83
|
const isRemoval = line.startsWith('-') && !line.startsWith('---')
|
|
73
84
|
const isHeader = line.startsWith('---') || line.startsWith('+++') || line.startsWith('@@')
|
|
@@ -76,8 +87,8 @@ function DiffLine({ line }: { line: string }) {
|
|
|
76
87
|
<div
|
|
77
88
|
className={clsx(
|
|
78
89
|
'whitespace-pre',
|
|
79
|
-
isAddition && 'bg-green-500/10 text-green-400',
|
|
80
|
-
isRemoval && 'bg-red-500/10 text-red-400',
|
|
90
|
+
isAddition && 'bg-green-500/10 text-green-700 dark:text-green-400',
|
|
91
|
+
isRemoval && 'bg-red-500/10 text-red-700 dark:text-red-400',
|
|
81
92
|
isHeader && 'text-theme-text-tertiary font-bold',
|
|
82
93
|
!isAddition && !isRemoval && !isHeader && 'text-theme-text-secondary'
|
|
83
94
|
)}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback } from 'react'
|
|
2
|
-
import { Link2, ExternalLink, AlertCircle, Terminal, FileText,
|
|
2
|
+
import { Link2, ExternalLink, AlertCircle, Terminal, FileText, X, Loader2 } from 'lucide-react'
|
|
3
3
|
import { getResourceIcon } from '../../utils/resource-icons'
|
|
4
4
|
import { clsx } from 'clsx'
|
|
5
5
|
import type { HelmOwnedResource } from '../../types'
|
|
@@ -8,10 +8,10 @@ import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
|
|
|
8
8
|
import { getResourceStatusColor, SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
9
9
|
import { useQueryClient } from '@tanstack/react-query'
|
|
10
10
|
import { useOpenTerminal, useOpenLogs } from '../dock'
|
|
11
|
-
import {
|
|
11
|
+
import { PortForwardInlineButton } from '../portforward/PortForwardButton'
|
|
12
12
|
import { useAvailablePorts } from '../../api/client'
|
|
13
13
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
14
|
-
import { useNamespacedCapabilities } from '../../contexts/CapabilitiesContext'
|
|
14
|
+
import { useNamespacedCapabilities, useIsLocalDeployment } from '../../contexts/CapabilitiesContext'
|
|
15
15
|
import { pluralize } from '@skyhook-io/k8s-ui'
|
|
16
16
|
import { Tooltip } from '../ui/Tooltip'
|
|
17
17
|
|
|
@@ -312,11 +312,14 @@ function PodQuickActions({ namespace, podName, isRunning }: PodQuickActionsProps
|
|
|
312
312
|
const queryClient = useQueryClient()
|
|
313
313
|
const openTerminal = useOpenTerminal()
|
|
314
314
|
const openLogs = useOpenLogs()
|
|
315
|
-
const startPortForward = useStartPortForward()
|
|
316
315
|
const { data: portsData, isLoading: portsLoading } = useAvailablePorts('pod', namespace, podName)
|
|
317
316
|
|
|
318
317
|
// Capabilities (namespace-scoped: re-checks RBAC if globally denied)
|
|
319
318
|
const { canExec, canViewLogs, canPortForward } = useNamespacedCapabilities(namespace)
|
|
319
|
+
// Live forward (local + RBAC) or the kubectl copy-command (in-cluster/Cloud);
|
|
320
|
+
// PortForwardInlineButton picks which by deployment mode.
|
|
321
|
+
const isLocal = useIsLocalDeployment()
|
|
322
|
+
const showPortForward = canPortForward || !isLocal
|
|
320
323
|
|
|
321
324
|
const [isLoadingAction, setIsLoadingAction] = useState(false)
|
|
322
325
|
|
|
@@ -374,14 +377,6 @@ function PodQuickActions({ namespace, podName, isRunning }: PodQuickActionsProps
|
|
|
374
377
|
}
|
|
375
378
|
}, [namespace, podName, openLogs, fetchPodData])
|
|
376
379
|
|
|
377
|
-
const handlePortForward = useCallback((port: number) => {
|
|
378
|
-
startPortForward.mutate({
|
|
379
|
-
namespace,
|
|
380
|
-
podName,
|
|
381
|
-
podPort: port,
|
|
382
|
-
})
|
|
383
|
-
}, [namespace, podName, startPortForward])
|
|
384
|
-
|
|
385
380
|
const ports = portsData?.ports || []
|
|
386
381
|
|
|
387
382
|
return (
|
|
@@ -414,21 +409,9 @@ function PodQuickActions({ namespace, podName, isRunning }: PodQuickActionsProps
|
|
|
414
409
|
</Tooltip>
|
|
415
410
|
)}
|
|
416
411
|
|
|
417
|
-
{/* Port Forward */}
|
|
418
|
-
{
|
|
419
|
-
<
|
|
420
|
-
<button
|
|
421
|
-
onClick={(e) => { e.stopPropagation(); handlePortForward(ports[0].port) }}
|
|
422
|
-
disabled={startPortForward.isPending}
|
|
423
|
-
className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-blue-500/10 rounded transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
|
424
|
-
>
|
|
425
|
-
{startPortForward.isPending ? (
|
|
426
|
-
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
427
|
-
) : (
|
|
428
|
-
<Plug className="w-3.5 h-3.5" />
|
|
429
|
-
)}
|
|
430
|
-
</button>
|
|
431
|
-
</Tooltip>
|
|
412
|
+
{/* Port Forward (live locally; kubectl copy-command in-cluster/Cloud) */}
|
|
413
|
+
{showPortForward && !portsLoading && ports.length > 0 && (
|
|
414
|
+
<PortForwardInlineButton namespace={namespace} podName={podName} port={ports[0].port} />
|
|
432
415
|
)}
|
|
433
416
|
</div>
|
|
434
417
|
)
|
|
@@ -441,37 +424,18 @@ interface ServiceQuickActionsProps {
|
|
|
441
424
|
}
|
|
442
425
|
|
|
443
426
|
function ServiceQuickActions({ namespace, serviceName }: ServiceQuickActionsProps) {
|
|
444
|
-
const startPortForward = useStartPortForward()
|
|
445
427
|
const { data: portsData, isLoading: portsLoading } = useAvailablePorts('service', namespace, serviceName)
|
|
446
428
|
const { canPortForward } = useNamespacedCapabilities(namespace)
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
startPortForward.mutate({
|
|
450
|
-
namespace,
|
|
451
|
-
serviceName,
|
|
452
|
-
podPort: port,
|
|
453
|
-
})
|
|
454
|
-
}, [namespace, serviceName, startPortForward])
|
|
429
|
+
const isLocal = useIsLocalDeployment()
|
|
430
|
+
const showPortForward = canPortForward || !isLocal
|
|
455
431
|
|
|
456
432
|
const ports = portsData?.ports || []
|
|
457
433
|
|
|
458
|
-
if (!
|
|
434
|
+
if (!showPortForward || portsLoading || ports.length === 0) return null
|
|
459
435
|
|
|
460
436
|
return (
|
|
461
437
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
462
|
-
<
|
|
463
|
-
<button
|
|
464
|
-
onClick={(e) => { e.stopPropagation(); handlePortForward(ports[0].port) }}
|
|
465
|
-
disabled={startPortForward.isPending}
|
|
466
|
-
className="p-1 text-theme-text-tertiary hover:text-blue-400 hover:bg-blue-500/10 rounded transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
|
467
|
-
>
|
|
468
|
-
{startPortForward.isPending ? (
|
|
469
|
-
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
470
|
-
) : (
|
|
471
|
-
<Plug className="w-3.5 h-3.5" />
|
|
472
|
-
)}
|
|
473
|
-
</button>
|
|
474
|
-
</Tooltip>
|
|
438
|
+
<PortForwardInlineButton namespace={namespace} serviceName={serviceName} port={ports[0].port} />
|
|
475
439
|
</div>
|
|
476
440
|
)
|
|
477
441
|
}
|
|
@@ -64,7 +64,7 @@ export function RevisionHistory({ history, currentRevision, operations = [], onV
|
|
|
64
64
|
{history.map((revision, index) => {
|
|
65
65
|
const isCurrent = revision.revision === currentRevision
|
|
66
66
|
const isSelectedForCompare = selectedForCompare === revision.revision
|
|
67
|
-
const annotations = operationAnnotationsForRevision(operations, revision.revision)
|
|
67
|
+
const annotations = operationAnnotationsForRevision(operations, revision.revision, revision.status)
|
|
68
68
|
|
|
69
69
|
return (
|
|
70
70
|
<div
|
|
@@ -174,7 +174,7 @@ export function RevisionHistory({ history, currentRevision, operations = [], onV
|
|
|
174
174
|
)
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
function operationAnnotationsForRevision(operations: HelmOperation[], revision: number): Array<{ label: string; className: string }> {
|
|
177
|
+
function operationAnnotationsForRevision(operations: HelmOperation[], revision: number, revisionStatus: string): Array<{ label: string; className: string }> {
|
|
178
178
|
const annotations: Array<{ label: string; className: string }> = []
|
|
179
179
|
const seen = new Set<string>()
|
|
180
180
|
const add = (label: string, className: string) => {
|
|
@@ -182,10 +182,14 @@ function operationAnnotationsForRevision(operations: HelmOperation[], revision:
|
|
|
182
182
|
seen.add(label)
|
|
183
183
|
annotations.push({ label, className })
|
|
184
184
|
}
|
|
185
|
+
const addFailure = (label: string) => {
|
|
186
|
+
if (revisionStatus.toLowerCase() === 'failed') return
|
|
187
|
+
add(label, SEVERITY_BADGE.error)
|
|
188
|
+
}
|
|
185
189
|
|
|
186
190
|
for (const op of operations) {
|
|
187
191
|
if (op.failedRevision === revision) {
|
|
188
|
-
|
|
192
|
+
addFailure('Failed upgrade')
|
|
189
193
|
}
|
|
190
194
|
if (op.rollbackRevision === revision) {
|
|
191
195
|
add('Rollback revision', SEVERITY_BADGE.warning)
|
|
@@ -193,10 +197,10 @@ function operationAnnotationsForRevision(operations: HelmOperation[], revision:
|
|
|
193
197
|
if (op.revision === revision) {
|
|
194
198
|
switch (op.kind) {
|
|
195
199
|
case 'upgrade_failed':
|
|
196
|
-
|
|
200
|
+
addFailure('Failed upgrade')
|
|
197
201
|
break
|
|
198
202
|
case 'release_failed':
|
|
199
|
-
|
|
203
|
+
addFailure('Failed')
|
|
200
204
|
break
|
|
201
205
|
case 'rollback':
|
|
202
206
|
add('Rollback', SEVERITY_BADGE.warning)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react'
|
|
1
|
+
import { useState, useCallback, useEffect } from 'react'
|
|
2
2
|
import { Copy, Check, Settings, Pencil, X, Eye, Play, Loader2 } from 'lucide-react'
|
|
3
3
|
import { PaneLoader } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import { clsx } from 'clsx'
|
|
@@ -21,6 +21,8 @@ interface ValuesViewerProps {
|
|
|
21
21
|
// Required for editing
|
|
22
22
|
namespace?: string
|
|
23
23
|
name?: string
|
|
24
|
+
revision?: number
|
|
25
|
+
currentRevision?: number
|
|
24
26
|
onApplySuccess?: () => void
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -33,6 +35,8 @@ export function ValuesViewer({
|
|
|
33
35
|
copied,
|
|
34
36
|
namespace,
|
|
35
37
|
name,
|
|
38
|
+
revision,
|
|
39
|
+
currentRevision,
|
|
36
40
|
onApplySuccess,
|
|
37
41
|
}: ValuesViewerProps) {
|
|
38
42
|
const [isEditing, setIsEditing] = useState(false)
|
|
@@ -44,8 +48,9 @@ export function ValuesViewer({
|
|
|
44
48
|
const previewMutation = useHelmPreviewValues()
|
|
45
49
|
const applyMutation = useHelmApplyValues()
|
|
46
50
|
const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
|
|
51
|
+
const isHistoricalRevision = typeof revision === 'number' && typeof currentRevision === 'number' && revision !== currentRevision
|
|
47
52
|
|
|
48
|
-
const canEdit = Boolean(namespace && name) && canHelmWrite
|
|
53
|
+
const canEdit = Boolean(namespace && name) && canHelmWrite && !isHistoricalRevision
|
|
49
54
|
|
|
50
55
|
const displayValues = showAllValues && values?.computed ? values.computed : values?.userSupplied
|
|
51
56
|
const isEmpty = !displayValues || Object.keys(displayValues).length === 0
|
|
@@ -72,6 +77,12 @@ export function ValuesViewer({
|
|
|
72
77
|
setShowPreview(false)
|
|
73
78
|
}, [])
|
|
74
79
|
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (isHistoricalRevision && isEditing) {
|
|
82
|
+
handleCancelEdit()
|
|
83
|
+
}
|
|
84
|
+
}, [isHistoricalRevision, isEditing, handleCancelEdit])
|
|
85
|
+
|
|
75
86
|
// Parse YAML and validate
|
|
76
87
|
const parseYaml = useCallback((yamlStr: string): Record<string, unknown> | null => {
|
|
77
88
|
try {
|
|
@@ -86,7 +97,7 @@ export function ValuesViewer({
|
|
|
86
97
|
|
|
87
98
|
// Preview changes
|
|
88
99
|
const handlePreview = useCallback(async () => {
|
|
89
|
-
if (!namespace || !name) return
|
|
100
|
+
if (!namespace || !name || isHistoricalRevision) return
|
|
90
101
|
const parsed = parseYaml(editedYaml)
|
|
91
102
|
if (!parsed) return
|
|
92
103
|
|
|
@@ -101,11 +112,11 @@ export function ValuesViewer({
|
|
|
101
112
|
} catch {
|
|
102
113
|
// Error is handled by mutation
|
|
103
114
|
}
|
|
104
|
-
}, [namespace, name, editedYaml, parseYaml, previewMutation])
|
|
115
|
+
}, [namespace, name, isHistoricalRevision, editedYaml, parseYaml, previewMutation])
|
|
105
116
|
|
|
106
117
|
// Apply changes
|
|
107
118
|
const handleApply = useCallback(async () => {
|
|
108
|
-
if (!namespace || !name) return
|
|
119
|
+
if (!namespace || !name || isHistoricalRevision) return
|
|
109
120
|
const parsed = parseYaml(editedYaml)
|
|
110
121
|
if (!parsed) return
|
|
111
122
|
|
|
@@ -120,11 +131,11 @@ export function ValuesViewer({
|
|
|
120
131
|
} catch {
|
|
121
132
|
// Error is handled by mutation
|
|
122
133
|
}
|
|
123
|
-
}, [namespace, name, editedYaml, parseYaml, applyMutation, handleCancelEdit, onApplySuccess])
|
|
134
|
+
}, [namespace, name, isHistoricalRevision, editedYaml, parseYaml, applyMutation, handleCancelEdit, onApplySuccess])
|
|
124
135
|
|
|
125
136
|
// Apply from preview modal
|
|
126
137
|
const handleApplyFromPreview = useCallback(async () => {
|
|
127
|
-
if (!previewData || !namespace || !name) return
|
|
138
|
+
if (!previewData || !namespace || !name || isHistoricalRevision) return
|
|
128
139
|
try {
|
|
129
140
|
await applyMutation.mutateAsync({
|
|
130
141
|
namespace,
|
|
@@ -137,7 +148,7 @@ export function ValuesViewer({
|
|
|
137
148
|
} catch {
|
|
138
149
|
// Error is handled by mutation
|
|
139
150
|
}
|
|
140
|
-
}, [previewData, namespace, name, applyMutation, handleCancelEdit, onApplySuccess])
|
|
151
|
+
}, [previewData, namespace, name, isHistoricalRevision, applyMutation, handleCancelEdit, onApplySuccess])
|
|
141
152
|
|
|
142
153
|
if (isLoading) {
|
|
143
154
|
return <PaneLoader label="Loading values…" className="h-32" />
|
|
@@ -147,7 +158,12 @@ export function ValuesViewer({
|
|
|
147
158
|
return (
|
|
148
159
|
<div className="p-4">
|
|
149
160
|
<div className="flex items-center justify-between mb-3">
|
|
150
|
-
<
|
|
161
|
+
<div className="flex items-center gap-2">
|
|
162
|
+
<span className="text-sm font-medium text-theme-text-secondary">Values</span>
|
|
163
|
+
{isHistoricalRevision && (
|
|
164
|
+
<span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">revision {revision}</span>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
151
167
|
<div className="flex items-center gap-2">
|
|
152
168
|
<ToggleButton showAll={showAllValues} onToggle={onToggleAllValues} disabled={isEditing} />
|
|
153
169
|
{canEdit && (
|
|
@@ -161,6 +177,11 @@ export function ValuesViewer({
|
|
|
161
177
|
)}
|
|
162
178
|
</div>
|
|
163
179
|
</div>
|
|
180
|
+
{isHistoricalRevision && (
|
|
181
|
+
<div className="mb-3 rounded border border-theme-border bg-theme-elevated/40 px-3 py-2 text-xs text-theme-text-secondary">
|
|
182
|
+
Viewing historical values. Switch back to the latest revision before editing or applying changes.
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
164
185
|
<div className="flex flex-col items-center justify-center h-32 text-theme-text-tertiary gap-2">
|
|
165
186
|
<Settings className="w-8 h-8 text-theme-text-disabled" />
|
|
166
187
|
<span>{showAllValues ? 'No computed values' : 'No user-supplied values'}</span>
|
|
@@ -179,6 +200,9 @@ export function ValuesViewer({
|
|
|
179
200
|
<span className="text-sm font-medium text-theme-text-secondary">
|
|
180
201
|
{isEditing ? 'Editing Values' : showAllValues ? 'All Values (Computed)' : 'User-Supplied Values'}
|
|
181
202
|
</span>
|
|
203
|
+
{isHistoricalRevision && !isEditing && (
|
|
204
|
+
<span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">revision {revision}</span>
|
|
205
|
+
)}
|
|
182
206
|
{isEditing && (
|
|
183
207
|
<span className="badge-sm bg-amber-500/20 text-amber-400 border-amber-500/30">
|
|
184
208
|
unsaved
|
|
@@ -218,7 +242,7 @@ export function ValuesViewer({
|
|
|
218
242
|
</button>
|
|
219
243
|
<button
|
|
220
244
|
onClick={handlePreview}
|
|
221
|
-
disabled={!!yamlError || previewMutation.isPending}
|
|
245
|
+
disabled={!!yamlError || previewMutation.isPending || isHistoricalRevision}
|
|
222
246
|
className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded border border-theme-border disabled:opacity-50 disabled:cursor-not-allowed"
|
|
223
247
|
>
|
|
224
248
|
{previewMutation.isPending ? (
|
|
@@ -231,7 +255,7 @@ export function ValuesViewer({
|
|
|
231
255
|
<Tooltip content={!canHelmWrite ? helmActReason : ''}>
|
|
232
256
|
<button
|
|
233
257
|
onClick={handleApply}
|
|
234
|
-
disabled={!!yamlError || applyMutation.isPending || !canHelmWrite}
|
|
258
|
+
disabled={!!yamlError || applyMutation.isPending || !canHelmWrite || isHistoricalRevision}
|
|
235
259
|
className="flex items-center gap-1 px-2.5 py-1 text-xs btn-brand rounded disabled:cursor-not-allowed disabled:pointer-events-none"
|
|
236
260
|
>
|
|
237
261
|
{applyMutation.isPending ? (
|
|
@@ -247,6 +271,12 @@ export function ValuesViewer({
|
|
|
247
271
|
</div>
|
|
248
272
|
</div>
|
|
249
273
|
|
|
274
|
+
{isHistoricalRevision && (
|
|
275
|
+
<div className="mb-3 rounded border border-theme-border bg-theme-elevated/40 px-3 py-2 text-xs text-theme-text-secondary">
|
|
276
|
+
Viewing historical values. Switch back to the latest revision before editing or applying changes.
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
|
|
250
280
|
{/* Error message */}
|
|
251
281
|
{yamlError && (
|
|
252
282
|
<div className="mb-3 px-3 py-2 text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
2
|
import type { DashboardResponse, DashboardMetrics, DashboardCRDCount } from '../../api/client'
|
|
3
3
|
import { HealthRing } from './HealthRing'
|
|
4
4
|
import {
|
|
@@ -30,6 +30,9 @@ interface ClusterHealthCardProps {
|
|
|
30
30
|
onNavigateToView: () => void
|
|
31
31
|
onWarningEventsClick?: () => void
|
|
32
32
|
onIssuesClick?: () => void
|
|
33
|
+
// Freshness/refresh control for the dashboard poll — rendered under the
|
|
34
|
+
// cluster metadata so the overview carries a freshness signal without a band.
|
|
35
|
+
freshness?: ReactNode
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
function getMetricsInstallHint(platform: string): string {
|
|
@@ -121,6 +124,7 @@ export function ClusterHealthCard({
|
|
|
121
124
|
onNavigateToView,
|
|
122
125
|
onWarningEventsClick,
|
|
123
126
|
onIssuesClick,
|
|
127
|
+
freshness,
|
|
124
128
|
}: ClusterHealthCardProps) {
|
|
125
129
|
void _topCRDs // Reserved for future CRD display
|
|
126
130
|
|
|
@@ -254,6 +258,7 @@ export function ClusterHealthCard({
|
|
|
254
258
|
</Tooltip>
|
|
255
259
|
)}
|
|
256
260
|
</div>
|
|
261
|
+
{freshness && <div className="mt-2">{freshness}</div>}
|
|
257
262
|
{nodeVersionSkew && (
|
|
258
263
|
<Tooltip
|
|
259
264
|
content={
|