@skyhook-io/radar-app 1.8.1 → 1.8.2
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 +1 -1
- package/src/App.tsx +3 -2
- package/src/api/client.ts +61 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +233 -19
- package/src/components/helm/HelmView.tsx +85 -16
- package/src/components/helm/RevisionHistory.tsx +46 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +5 -5
- package/src/components/nav/PrimaryNavRail.tsx +1 -1
- package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
- package/src/components/ui/command-items.ts +1 -1
- package/src/context/ConnectionContext.tsx +29 -2
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -1410,7 +1410,7 @@ function AppInner() {
|
|
|
1410
1410
|
// the bar is full, and the view's primary home is Cloud's fleet
|
|
1411
1411
|
// rail. The view still exists and is reachable via /applications
|
|
1412
1412
|
// and the view-switching shortcuts. Same treatment as Cost below.
|
|
1413
|
-
{ view: 'traffic' as const, icon: Activity, label: 'Traffic' },
|
|
1413
|
+
{ view: 'traffic' as const, icon: Activity, label: 'Live Traffic' },
|
|
1414
1414
|
// Cost is intentionally hidden from the pill bar for now — the view still
|
|
1415
1415
|
// exists and is reachable via /cost, the Home dashboard card, and the
|
|
1416
1416
|
// command palette (⌘K). Remove this comment to restore it.
|
|
@@ -1793,7 +1793,7 @@ function AppInner() {
|
|
|
1793
1793
|
<TopologySearch
|
|
1794
1794
|
nodes={filteredTopology?.nodes ?? []}
|
|
1795
1795
|
allNodes={topology?.nodes}
|
|
1796
|
-
viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? '
|
|
1796
|
+
viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Network Flow' : 'Resources'}
|
|
1797
1797
|
onNodeSelect={handleNodeClick}
|
|
1798
1798
|
onZoomToNode={(id) => setTopologyFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }))}
|
|
1799
1799
|
// Stack below the namespace breadcrumb (shown only for a single
|
|
@@ -1815,6 +1815,7 @@ function AppInner() {
|
|
|
1815
1815
|
showPolicyEffect={showPolicyEffect}
|
|
1816
1816
|
onShowPolicyEffectChange={setShowPolicyEffect}
|
|
1817
1817
|
showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
|
|
1818
|
+
onNavigateToTraffic={() => setMainView('traffic')}
|
|
1818
1819
|
/>
|
|
1819
1820
|
</div>
|
|
1820
1821
|
</>
|
package/src/api/client.ts
CHANGED
|
@@ -2396,6 +2396,19 @@ export function useHelmUpgradeInfo(namespace: string, name: string, enabled = tr
|
|
|
2396
2396
|
})
|
|
2397
2397
|
}
|
|
2398
2398
|
|
|
2399
|
+
// Available chart versions for a release (newest-first), for the upgrade dialog's
|
|
2400
|
+
// version picker. Empty when the source can't be resolved — the dialog then falls
|
|
2401
|
+
// back to the latest version from upgrade-info.
|
|
2402
|
+
export function useHelmReleaseVersions(namespace: string, name: string, enabled = true) {
|
|
2403
|
+
return useQuery<string[]>({
|
|
2404
|
+
queryKey: ['helm-release-versions', namespace, name],
|
|
2405
|
+
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/versions`),
|
|
2406
|
+
enabled: Boolean(namespace && name && enabled),
|
|
2407
|
+
staleTime: 30000,
|
|
2408
|
+
retry: false,
|
|
2409
|
+
})
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2399
2412
|
// Batch check for upgrade availability (for list view)
|
|
2400
2413
|
export function useHelmBatchUpgradeInfo(namespaces: string[] = [], enabled = true) {
|
|
2401
2414
|
const params = helmNamespaceParams(namespaces)
|
|
@@ -2668,6 +2681,54 @@ export function useUpdateRepositorySilent() {
|
|
|
2668
2681
|
})
|
|
2669
2682
|
}
|
|
2670
2683
|
|
|
2684
|
+
// Registered OCI chart sources (the OCI analog of `helm repo add`). Used to
|
|
2685
|
+
// track upgrades for the user's own OCI-published charts.
|
|
2686
|
+
export function useHelmOCISources() {
|
|
2687
|
+
return useQuery<string[]>({
|
|
2688
|
+
queryKey: ['helm-oci-sources'],
|
|
2689
|
+
queryFn: () => fetchJSON('/helm/oci-sources'),
|
|
2690
|
+
})
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
async function mutateOCISource(method: 'POST' | 'DELETE', source: string): Promise<string[]> {
|
|
2694
|
+
const response = await apiFetch(`${getApiBase()}/helm/oci-sources`, {
|
|
2695
|
+
method,
|
|
2696
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2697
|
+
body: JSON.stringify({ source }),
|
|
2698
|
+
})
|
|
2699
|
+
if (!response.ok) {
|
|
2700
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2701
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
2702
|
+
}
|
|
2703
|
+
return response.json()
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
// Invalidate the upgrade-info queries so a newly-registered source is probed
|
|
2707
|
+
// immediately and "source not tracked" re-resolves.
|
|
2708
|
+
function invalidateHelmAfterSourceChange(queryClient: ReturnType<typeof useQueryClient>) {
|
|
2709
|
+
queryClient.invalidateQueries({ queryKey: ['helm-oci-sources'] })
|
|
2710
|
+
queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info'] })
|
|
2711
|
+
queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
export function useAddOCISource() {
|
|
2715
|
+
const queryClient = useQueryClient()
|
|
2716
|
+
return useMutation({
|
|
2717
|
+
mutationFn: (source: string) => mutateOCISource('POST', source),
|
|
2718
|
+
meta: { errorMessage: 'Failed to add chart source', successMessage: 'Chart source added' },
|
|
2719
|
+
onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
|
|
2720
|
+
})
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
export function useRemoveOCISource() {
|
|
2724
|
+
const queryClient = useQueryClient()
|
|
2725
|
+
return useMutation({
|
|
2726
|
+
mutationFn: (source: string) => mutateOCISource('DELETE', source),
|
|
2727
|
+
meta: { errorMessage: 'Failed to remove chart source', successMessage: 'Chart source removed' },
|
|
2728
|
+
onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
|
|
2729
|
+
})
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2671
2732
|
// Search charts across all repositories
|
|
2672
2733
|
export function useSearchCharts(query: string, allVersions = false, enabled = true) {
|
|
2673
2734
|
return useQuery<ChartSearchResult>({
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
|
-
import { FetchResult, useDockReservedHeight } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { FetchResult, useDockReservedHeight, compareVersions } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
|
|
5
5
|
import { TRANSITION_DRAWER } from '../../utils/animation'
|
|
6
6
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
7
|
-
import { X, Copy, Check, RefreshCw, Package, Code, History, FileText, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2, GitBranch } from 'lucide-react'
|
|
7
|
+
import { X, Copy, Check, RefreshCw, Package, Code, History, FileText, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2, GitBranch, AlertTriangle, RotateCcw, Clock } from 'lucide-react'
|
|
8
8
|
import { useNavigate } from 'react-router-dom'
|
|
9
9
|
import { clsx } from 'clsx'
|
|
10
|
-
import { useHelmRelease, useHelmManifest, useHelmValues, useHelmManifestDiff, useHelmUpgradeInfo, useHelmUninstall, upgradeWithProgress, rollbackWithProgress } from '../../api/client'
|
|
10
|
+
import { useHelmRelease, useHelmManifest, useHelmValues, useHelmManifestDiff, useHelmUpgradeInfo, useHelmReleaseVersions, useHelmUninstall, upgradeWithProgress, rollbackWithProgress } from '../../api/client'
|
|
11
11
|
import { useQueryClient } from '@tanstack/react-query'
|
|
12
12
|
import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
13
13
|
import { Tooltip } from '../ui/Tooltip'
|
|
14
14
|
import { Markdown } from '../ui/Markdown'
|
|
15
|
-
import type { SelectedHelmRelease, HelmHook, ChartDependency } from '../../types'
|
|
15
|
+
import type { SelectedHelmRelease, HelmHook, ChartDependency, HelmOperation } from '../../types'
|
|
16
16
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
17
17
|
import { formatDate } from './helm-utils'
|
|
18
18
|
import { getHelmStatusColor, SEVERITY_BADGE, SEVERITY_TEXT } from '../../utils/badge-colors'
|
|
@@ -23,6 +23,7 @@ import { ManifestViewer } from './ManifestViewer'
|
|
|
23
23
|
import { ValuesViewer } from './ValuesViewer'
|
|
24
24
|
import { OwnedResources } from './OwnedResources'
|
|
25
25
|
import { ManifestDiffViewer } from './ManifestDiffViewer'
|
|
26
|
+
import { TrackChartSourceDialog } from './TrackChartSourceDialog'
|
|
26
27
|
|
|
27
28
|
interface HelmReleaseDrawerProps {
|
|
28
29
|
release: SelectedHelmRelease
|
|
@@ -50,6 +51,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
50
51
|
const [rollbackRevision, setRollbackRevision] = useState<number | null>(null)
|
|
51
52
|
const [showUninstallConfirm, setShowUninstallConfirm] = useState(false)
|
|
52
53
|
const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false)
|
|
54
|
+
const [showTrackSource, setShowTrackSource] = useState(false)
|
|
55
|
+
const [selectedVersion, setSelectedVersion] = useState<string | null>(null)
|
|
53
56
|
const resizeStartX = useRef(0)
|
|
54
57
|
const resizeStartWidth = useRef(DEFAULT_WIDTH)
|
|
55
58
|
const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
|
|
@@ -99,6 +102,17 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
99
102
|
)
|
|
100
103
|
const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade check failed'
|
|
101
104
|
|
|
105
|
+
// Available versions for the upgrade dialog's picker — only fetched while the
|
|
106
|
+
// confirm dialog is open. Default the selection to latest when it opens.
|
|
107
|
+
const { data: availableVersions } = useHelmReleaseVersions(helmNamespace, release.name, showUpgradeConfirm)
|
|
108
|
+
const targetVersion = selectedVersion ?? upgradeInfo?.latestVersion ?? ''
|
|
109
|
+
// Semver compare, not list-position: the installed version may be older than
|
|
110
|
+
// the newest-N versions the picker shows, so it isn't always in the list.
|
|
111
|
+
const isDowngrade = Boolean(
|
|
112
|
+
targetVersion && upgradeInfo?.currentVersion &&
|
|
113
|
+
compareVersions(targetVersion, upgradeInfo.currentVersion) === -1
|
|
114
|
+
)
|
|
115
|
+
|
|
102
116
|
// Mutations for actions
|
|
103
117
|
const uninstallMutation = useHelmUninstall()
|
|
104
118
|
const queryClient = useQueryClient()
|
|
@@ -234,7 +248,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
234
248
|
}
|
|
235
249
|
|
|
236
250
|
const handleUpgradeConfirm = async () => {
|
|
237
|
-
if (!
|
|
251
|
+
if (!targetVersion) return
|
|
238
252
|
setIsUpgrading(true)
|
|
239
253
|
setUpgradeProgress([])
|
|
240
254
|
|
|
@@ -242,8 +256,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
242
256
|
await upgradeWithProgress(
|
|
243
257
|
helmNamespace,
|
|
244
258
|
release.name,
|
|
245
|
-
|
|
246
|
-
upgradeInfo
|
|
259
|
+
targetVersion,
|
|
260
|
+
upgradeInfo?.repositoryName,
|
|
247
261
|
(event) => {
|
|
248
262
|
if (event.type === 'progress' && event.message) {
|
|
249
263
|
setUpgradeProgress(prev => [...prev, {
|
|
@@ -256,7 +270,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
256
270
|
|
|
257
271
|
setUpgradeProgress(prev => [...prev, {
|
|
258
272
|
phase: 'complete',
|
|
259
|
-
message: `Successfully upgraded to ${
|
|
273
|
+
message: `Successfully upgraded to ${targetVersion}`,
|
|
260
274
|
}])
|
|
261
275
|
|
|
262
276
|
// Invalidate queries
|
|
@@ -268,6 +282,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
268
282
|
setTimeout(() => {
|
|
269
283
|
setShowUpgradeConfirm(false)
|
|
270
284
|
setUpgradeProgress([])
|
|
285
|
+
setSelectedVersion(null)
|
|
271
286
|
refetch()
|
|
272
287
|
switchTab('resources')
|
|
273
288
|
}, 1500)
|
|
@@ -342,8 +357,18 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
342
357
|
upgrade check failed
|
|
343
358
|
</span>
|
|
344
359
|
</Tooltip>
|
|
360
|
+
) : upgradeInfo?.updateAvailable && releaseDetail?.managedByFluxHelmRelease ? (
|
|
361
|
+
// Route-only for GitOps-managed releases: a direct `helm upgrade`
|
|
362
|
+
// would be reverted at the next reconcile, so surface the available
|
|
363
|
+
// version as info and point at GitOps rather than offer the upgrade.
|
|
364
|
+
<Tooltip content={`${upgradeInfo.latestVersion} available — managed by Flux, upgrade via the GitOps view (a direct upgrade would be reverted at the next reconcile).`}>
|
|
365
|
+
<span className={clsx('badge', SEVERITY_BADGE.warning, 'opacity-90')}>
|
|
366
|
+
<ArrowUpCircle className="w-3 h-3" />
|
|
367
|
+
{upgradeInfo.latestVersion}
|
|
368
|
+
</span>
|
|
369
|
+
</Tooltip>
|
|
345
370
|
) : upgradeInfo?.updateAvailable ? (
|
|
346
|
-
<Tooltip content={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : ''}` : helmActReason}>
|
|
371
|
+
<Tooltip content={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : upgradeInfo.sourceType === 'oci' ? ' (OCI)' : ''}` : helmActReason}>
|
|
347
372
|
<button
|
|
348
373
|
onClick={() => setShowUpgradeConfirm(true)}
|
|
349
374
|
disabled={!canHelmWrite}
|
|
@@ -363,13 +388,39 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
363
388
|
</span>
|
|
364
389
|
</Tooltip>
|
|
365
390
|
) : upgradeInfo?.error ? (
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
391
|
+
releaseDetail?.managedByFluxHelmRelease ? (
|
|
392
|
+
// Managed by Flux — the "Managed by Flux" badge routes to GitOps,
|
|
393
|
+
// where the chart source lives. Don't push a Helm source here.
|
|
394
|
+
<Tooltip content="Chart source is managed by Flux — track upgrades from the GitOps view.">
|
|
395
|
+
<span className="badge bg-theme-hover/50 text-theme-text-secondary">
|
|
396
|
+
source via GitOps
|
|
397
|
+
</span>
|
|
398
|
+
</Tooltip>
|
|
399
|
+
) : upgradeInfo.untracked ? (
|
|
400
|
+
// Helm doesn't record the install source. Offer to register one so
|
|
401
|
+
// Radar can track upgrades for the user's own (e.g. OCI) charts.
|
|
402
|
+
<Tooltip content={canHelmWrite ? "Radar can't tell where this chart was installed from. Register your chart source to track upgrades." : upgradeInfo.error}>
|
|
403
|
+
<button
|
|
404
|
+
onClick={() => canHelmWrite && setShowTrackSource(true)}
|
|
405
|
+
disabled={!canHelmWrite}
|
|
406
|
+
className={clsx(
|
|
407
|
+
'badge transition-colors disabled:pointer-events-none bg-theme-hover/50 text-theme-text-secondary',
|
|
408
|
+
canHelmWrite ? 'hover:bg-theme-hover cursor-pointer' : 'opacity-50 cursor-not-allowed'
|
|
409
|
+
)}
|
|
410
|
+
>
|
|
411
|
+
<Link2 className="w-3 h-3" />
|
|
412
|
+
source not tracked
|
|
413
|
+
</button>
|
|
414
|
+
</Tooltip>
|
|
415
|
+
) : (
|
|
416
|
+
// Repo-side error (stale/broken index, classic ambiguity) — not an
|
|
417
|
+
// OCI tracking issue, so surface it without steering to registration.
|
|
418
|
+
<Tooltip content={upgradeInfo.error}>
|
|
419
|
+
<span className="badge bg-theme-hover/50 text-theme-text-secondary">
|
|
420
|
+
upgrade source unresolved
|
|
421
|
+
</span>
|
|
422
|
+
</Tooltip>
|
|
423
|
+
)
|
|
373
424
|
) : null}
|
|
374
425
|
</div>
|
|
375
426
|
<div className="flex items-center gap-1">
|
|
@@ -462,6 +513,10 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
462
513
|
<FetchResult loading={isLoading} error={releaseError} notFoundMessage="Release not found" className="h-32" />
|
|
463
514
|
) : (
|
|
464
515
|
<>
|
|
516
|
+
<HelmOperationBanner
|
|
517
|
+
operation={releaseDetail.lastOperation}
|
|
518
|
+
managedByFluxHelmRelease={releaseDetail.managedByFluxHelmRelease}
|
|
519
|
+
/>
|
|
465
520
|
{activeTab === 'overview' && (
|
|
466
521
|
<OverviewTab release={releaseDetail} onCopy={copyToClipboard} copied={copied} />
|
|
467
522
|
)}
|
|
@@ -469,6 +524,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
469
524
|
<RevisionHistory
|
|
470
525
|
history={releaseDetail.history}
|
|
471
526
|
currentRevision={releaseDetail.revision}
|
|
527
|
+
operations={mergeHelmOperations(releaseDetail.operations, releaseDetail.lastOperation)}
|
|
472
528
|
onViewRevision={handleViewRevision}
|
|
473
529
|
onCompare={handleCompareRevisions}
|
|
474
530
|
onRollback={canHelmWrite ? handleRollbackRequest : undefined}
|
|
@@ -572,6 +628,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
572
628
|
onClose={() => {
|
|
573
629
|
setShowUpgradeConfirm(false)
|
|
574
630
|
setUpgradeProgress([])
|
|
631
|
+
setSelectedVersion(null)
|
|
575
632
|
if (isUpgrading) {
|
|
576
633
|
// Upgrade continues server-side — switch to resources tab to monitor
|
|
577
634
|
setIsUpgrading(false)
|
|
@@ -580,18 +637,56 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
580
637
|
}}
|
|
581
638
|
onConfirm={handleUpgradeConfirm}
|
|
582
639
|
title="Upgrade Release"
|
|
583
|
-
message={`Upgrade "${release.name}" to version ${
|
|
640
|
+
message={`Upgrade "${release.name}" to version ${targetVersion}?`}
|
|
584
641
|
details={upgradeProgress.length === 0
|
|
585
|
-
? `
|
|
642
|
+
? `The chart will move from version ${upgradeInfo?.currentVersion} to ${targetVersion}. Your existing values will be preserved. The change is applied to your cluster immediately.`
|
|
586
643
|
: undefined
|
|
587
644
|
}
|
|
588
|
-
confirmLabel=
|
|
645
|
+
confirmLabel={isDowngrade ? 'Downgrade' : 'Upgrade'}
|
|
589
646
|
variant="warning"
|
|
590
647
|
isLoading={isUpgrading}
|
|
591
648
|
isClosable
|
|
592
649
|
>
|
|
650
|
+
{upgradeProgress.length === 0 && availableVersions && availableVersions.length > 1 && (
|
|
651
|
+
<div className="mb-1">
|
|
652
|
+
<label htmlFor="upgrade-version" className="block text-sm font-medium text-theme-text-secondary mb-1.5">
|
|
653
|
+
Target version
|
|
654
|
+
</label>
|
|
655
|
+
<select
|
|
656
|
+
id="upgrade-version"
|
|
657
|
+
value={targetVersion}
|
|
658
|
+
onChange={(e) => setSelectedVersion(e.target.value)}
|
|
659
|
+
disabled={isUpgrading}
|
|
660
|
+
className="w-full px-3 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
|
|
661
|
+
>
|
|
662
|
+
{availableVersions.map((v) => (
|
|
663
|
+
<option key={v} value={v}>
|
|
664
|
+
{v}
|
|
665
|
+
{v === upgradeInfo?.latestVersion ? ' (latest)' : ''}
|
|
666
|
+
{v === upgradeInfo?.currentVersion ? ' (current)' : ''}
|
|
667
|
+
</option>
|
|
668
|
+
))}
|
|
669
|
+
</select>
|
|
670
|
+
{isDowngrade && (
|
|
671
|
+
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
|
672
|
+
This is a downgrade from {upgradeInfo?.currentVersion}.
|
|
673
|
+
</p>
|
|
674
|
+
)}
|
|
675
|
+
{availableVersions.length >= 50 && (
|
|
676
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
677
|
+
Showing the 50 newest versions. Type to filter.
|
|
678
|
+
</p>
|
|
679
|
+
)}
|
|
680
|
+
</div>
|
|
681
|
+
)}
|
|
593
682
|
{upgradeProgress.length > 0 && <ProgressLog entries={upgradeProgress} />}
|
|
594
683
|
</ConfirmDialog>
|
|
684
|
+
|
|
685
|
+
<TrackChartSourceDialog
|
|
686
|
+
open={showTrackSource}
|
|
687
|
+
onClose={() => setShowTrackSource(false)}
|
|
688
|
+
chartName={releaseDetail?.chart}
|
|
689
|
+
/>
|
|
595
690
|
</div>
|
|
596
691
|
)
|
|
597
692
|
}
|
|
@@ -623,6 +718,125 @@ function ProgressLog({ entries }: { entries: { phase: string; message: string }[
|
|
|
623
718
|
)
|
|
624
719
|
}
|
|
625
720
|
|
|
721
|
+
function mergeHelmOperations(operations: HelmOperation[] | undefined, lastOperation: HelmOperation | undefined): HelmOperation[] {
|
|
722
|
+
const merged: HelmOperation[] = []
|
|
723
|
+
const seen = new Set<string>()
|
|
724
|
+
for (const op of [...(operations || []), ...(lastOperation ? [lastOperation] : [])]) {
|
|
725
|
+
const key = helmOperationKey(op)
|
|
726
|
+
if (seen.has(key)) continue
|
|
727
|
+
seen.add(key)
|
|
728
|
+
merged.push(op)
|
|
729
|
+
}
|
|
730
|
+
return merged
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function helmOperationKey(operation: HelmOperation): string {
|
|
734
|
+
return [
|
|
735
|
+
operation.kind,
|
|
736
|
+
operation.status,
|
|
737
|
+
operation.revision || 0,
|
|
738
|
+
operation.failedRevision || 0,
|
|
739
|
+
operation.rollbackRevision || 0,
|
|
740
|
+
operation.targetRevision || 0,
|
|
741
|
+
].join(':')
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function HelmOperationBanner({
|
|
745
|
+
operation,
|
|
746
|
+
managedByFluxHelmRelease,
|
|
747
|
+
}: {
|
|
748
|
+
operation?: HelmOperation
|
|
749
|
+
managedByFluxHelmRelease?: string
|
|
750
|
+
}) {
|
|
751
|
+
if (!operation || !shouldShowOperationBanner(operation)) {
|
|
752
|
+
return null
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const isFailure = operation.status === 'failed'
|
|
756
|
+
const isPending = operation.status === 'stuck_pending'
|
|
757
|
+
const tone: 'error' | 'warning' | 'info' = isFailure ? 'error' : operation.kind === 'rollback' ? 'info' : 'warning'
|
|
758
|
+
const Icon = operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' ? RotateCcw : isPending ? Clock : AlertTriangle
|
|
759
|
+
const title = operationTitle(operation)
|
|
760
|
+
|
|
761
|
+
return (
|
|
762
|
+
<div className="m-4 mb-0 card-inner-lg">
|
|
763
|
+
<div className="flex items-start gap-3">
|
|
764
|
+
<Icon className={clsx('mt-0.5 h-5 w-5 shrink-0', SEVERITY_TEXT[tone])} />
|
|
765
|
+
<div className="min-w-0 flex-1">
|
|
766
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
767
|
+
<span className="text-sm font-medium text-theme-text-primary">{title}</span>
|
|
768
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{operation.status.replace(/_/g, ' ')}</span>
|
|
769
|
+
<OperationRevisionChips operation={operation} />
|
|
770
|
+
</div>
|
|
771
|
+
<p className="mt-1 text-sm text-theme-text-secondary">{operation.message}</p>
|
|
772
|
+
{operation.failureDescription && (
|
|
773
|
+
<p className="mt-1 text-xs text-theme-text-tertiary truncate">
|
|
774
|
+
{operation.failureDescription}
|
|
775
|
+
</p>
|
|
776
|
+
)}
|
|
777
|
+
{operation.kind === 'upgrade_rolled_back' && (
|
|
778
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
779
|
+
Helm history does not record whether <code className="inline-code text-[11px]">--atomic</code> was set; the rollback is inferred from adjacent release revisions.
|
|
780
|
+
</p>
|
|
781
|
+
)}
|
|
782
|
+
{managedByFluxHelmRelease && (
|
|
783
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
784
|
+
This release is managed by Flux HelmRelease {managedByFluxHelmRelease}; direct Helm changes may be reconciled back.
|
|
785
|
+
</p>
|
|
786
|
+
)}
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
</div>
|
|
790
|
+
)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function shouldShowOperationBanner(operation: HelmOperation): boolean {
|
|
794
|
+
return operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' || operation.status === 'failed' || operation.status === 'stuck_pending'
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function operationTitle(operation: HelmOperation): string {
|
|
798
|
+
switch (operation.kind) {
|
|
799
|
+
case 'upgrade_rolled_back':
|
|
800
|
+
return 'Helm rolled back after failed upgrade'
|
|
801
|
+
case 'rollback':
|
|
802
|
+
return 'Helm rollback applied'
|
|
803
|
+
case 'pending':
|
|
804
|
+
return 'Helm operation may be stuck'
|
|
805
|
+
case 'upgrade_failed':
|
|
806
|
+
return 'Helm upgrade failed'
|
|
807
|
+
case 'release_failed':
|
|
808
|
+
return 'Helm release failed'
|
|
809
|
+
default:
|
|
810
|
+
return 'Helm operation'
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function OperationRevisionChips({ operation }: { operation: HelmOperation }) {
|
|
815
|
+
const chips: Array<{ key: string; label: string; className: string }> = []
|
|
816
|
+
if (operation.failedRevision) {
|
|
817
|
+
chips.push({ key: 'failed', label: `failed rev ${operation.failedRevision}`, className: SEVERITY_BADGE.error })
|
|
818
|
+
}
|
|
819
|
+
if (operation.rollbackRevision) {
|
|
820
|
+
chips.push({ key: 'rollback', label: `rollback rev ${operation.rollbackRevision}`, className: SEVERITY_BADGE.warning })
|
|
821
|
+
}
|
|
822
|
+
if (operation.targetRevision) {
|
|
823
|
+
chips.push({ key: 'target', label: `target rev ${operation.targetRevision}`, className: SEVERITY_BADGE.info })
|
|
824
|
+
}
|
|
825
|
+
if (!operation.failedRevision && !operation.rollbackRevision && operation.revision) {
|
|
826
|
+
chips.push({ key: 'revision', label: `rev ${operation.revision}`, className: SEVERITY_BADGE.neutral })
|
|
827
|
+
}
|
|
828
|
+
if (chips.length === 0) {
|
|
829
|
+
return null
|
|
830
|
+
}
|
|
831
|
+
return (
|
|
832
|
+
<>
|
|
833
|
+
{chips.map((chip) => (
|
|
834
|
+
<span key={chip.key} className={clsx('badge-sm', chip.className)}>{chip.label}</span>
|
|
835
|
+
))}
|
|
836
|
+
</>
|
|
837
|
+
)
|
|
838
|
+
}
|
|
839
|
+
|
|
626
840
|
// Overview tab content
|
|
627
841
|
interface OverviewTabProps {
|
|
628
842
|
release: {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { useState, useMemo, useRef, useEffect, useCallback, forwardRef } from 'react'
|
|
2
2
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
3
3
|
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
4
|
-
import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight } from 'lucide-react'
|
|
4
|
+
import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight, RotateCcw, Clock } from 'lucide-react'
|
|
5
5
|
import { PaneLoader, PageHeader } from '@skyhook-io/k8s-ui'
|
|
6
6
|
import { clsx } from 'clsx'
|
|
7
7
|
import { useHelmReleases, useHelmBatchUpgradeInfo, isForbiddenError } from '../../api/client'
|
|
8
|
-
import type { HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
|
|
9
|
-
import { getStatusColor, formatAge,
|
|
8
|
+
import type { HelmOperation, HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
|
|
9
|
+
import { getStatusColor, formatAge, isHelmReleaseActionable } from './helm-utils'
|
|
10
10
|
import { SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
11
11
|
import { Tooltip } from '../ui/Tooltip'
|
|
12
12
|
import { ChartBrowser } from './ChartBrowser'
|
|
@@ -291,22 +291,22 @@ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmVi
|
|
|
291
291
|
<table className="w-full table-fixed">
|
|
292
292
|
<thead className="bg-theme-surface sticky top-0 z-10">
|
|
293
293
|
<tr>
|
|
294
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide">
|
|
294
|
+
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[28%]">
|
|
295
295
|
Name
|
|
296
296
|
</th>
|
|
297
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-
|
|
297
|
+
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[18%]">
|
|
298
298
|
Namespace
|
|
299
299
|
</th>
|
|
300
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-
|
|
300
|
+
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-[22%]">
|
|
301
301
|
Chart
|
|
302
302
|
</th>
|
|
303
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
304
|
App Version
|
|
305
305
|
</th>
|
|
306
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-
|
|
306
|
+
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-40">
|
|
307
307
|
Status
|
|
308
308
|
</th>
|
|
309
|
-
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-
|
|
309
|
+
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-16">
|
|
310
310
|
Rev
|
|
311
311
|
</th>
|
|
312
312
|
<th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-24">
|
|
@@ -411,8 +411,74 @@ function getActionableTooltip(issue: string | undefined, summary: string | undef
|
|
|
411
411
|
)
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
function getListOperation(release: HelmRelease): HelmOperation | undefined {
|
|
415
|
+
if (release.lastOperation) {
|
|
416
|
+
const releaseStatus = release.status.toLowerCase()
|
|
417
|
+
if (release.lastOperation.status === 'failed' && releaseStatus === 'failed') {
|
|
418
|
+
return undefined
|
|
419
|
+
}
|
|
420
|
+
if (isListOperation(release.lastOperation)) {
|
|
421
|
+
return release.lastOperation
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return release.operations?.find(isListOperation)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function isListOperation(operation: HelmOperation): boolean {
|
|
428
|
+
return operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' || operation.status === 'stuck_pending'
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function HelmOperationChip({ operation }: { operation: HelmOperation }) {
|
|
432
|
+
const isPending = operation.status === 'stuck_pending'
|
|
433
|
+
const isRollback = operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback'
|
|
434
|
+
const Icon = isRollback ? RotateCcw : Clock
|
|
435
|
+
const tone: keyof typeof SEVERITY_BADGE = operation.kind === 'rollback' ? 'info' : isPending ? 'warning' : 'alert'
|
|
436
|
+
const label = operation.kind === 'upgrade_rolled_back'
|
|
437
|
+
? 'rolled back'
|
|
438
|
+
: operation.kind === 'rollback'
|
|
439
|
+
? 'rollback'
|
|
440
|
+
: 'stuck'
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<Tooltip content={
|
|
444
|
+
<div className="max-w-xs">
|
|
445
|
+
<div className="font-medium text-theme-text-primary">{operationSummary(operation)}</div>
|
|
446
|
+
<div className="mt-1 text-[10px] text-theme-text-secondary">{operation.message}</div>
|
|
447
|
+
{operation.failureDescription && (
|
|
448
|
+
<div className="mt-1.5 border-t border-theme-border pt-1.5 text-[10px] text-theme-text-tertiary">
|
|
449
|
+
{operation.failureDescription}
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
452
|
+
</div>
|
|
453
|
+
}>
|
|
454
|
+
<span className={clsx('badge-sm shrink-0', SEVERITY_BADGE[tone])} aria-label={operationSummary(operation)}>
|
|
455
|
+
<Icon className="h-3 w-3" />
|
|
456
|
+
<span className="sr-only">{label}</span>
|
|
457
|
+
<span className="hidden 2xl:inline" aria-hidden="true">{label}</span>
|
|
458
|
+
</span>
|
|
459
|
+
</Tooltip>
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function operationSummary(operation: HelmOperation): string {
|
|
464
|
+
switch (operation.kind) {
|
|
465
|
+
case 'upgrade_rolled_back':
|
|
466
|
+
return 'Helm rolled back after failed upgrade'
|
|
467
|
+
case 'rollback':
|
|
468
|
+
return 'Helm rollback applied'
|
|
469
|
+
case 'pending':
|
|
470
|
+
return 'Helm operation may be stuck'
|
|
471
|
+
case 'upgrade_failed':
|
|
472
|
+
return 'Helm upgrade failed'
|
|
473
|
+
default:
|
|
474
|
+
return 'Helm release failed'
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
414
478
|
const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
|
|
415
479
|
function ReleaseRow({ release, upgradeInfo, isSelected, isHighlighted, onClick, onMouseEnter }, ref) {
|
|
480
|
+
const listOperation = getListOperation(release)
|
|
481
|
+
|
|
416
482
|
// Health badge styling
|
|
417
483
|
const getHealthBadge = () => {
|
|
418
484
|
if (!release.resourceHealth || release.resourceHealth === 'unknown') return null
|
|
@@ -454,10 +520,11 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
|
|
|
454
520
|
)}
|
|
455
521
|
>
|
|
456
522
|
<td className="px-4 py-3">
|
|
457
|
-
<div className="flex items-center gap-2">
|
|
523
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
458
524
|
<Package className="w-4 h-4 text-theme-text-tertiary shrink-0" />
|
|
459
|
-
<span className="text-sm text-theme-text-primary
|
|
525
|
+
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{release.name}</span>
|
|
460
526
|
{getHealthBadge()}
|
|
527
|
+
{listOperation && <HelmOperationChip operation={listOperation} />}
|
|
461
528
|
{release.managedByFluxHelmRelease && (
|
|
462
529
|
<Tooltip content={`Installed by Flux helm-controller via HelmRelease ${release.managedByFluxHelmRelease}. Changes here will be reverted at the next reconcile — manage via the GitOps tab.`}>
|
|
463
530
|
<span className="badge-sm shrink-0 border border-theme-border bg-theme-elevated text-theme-text-secondary">
|
|
@@ -475,13 +542,15 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
|
|
|
475
542
|
)}
|
|
476
543
|
</div>
|
|
477
544
|
</td>
|
|
478
|
-
<td className="px-4 py-3 w-
|
|
479
|
-
<
|
|
545
|
+
<td className="px-4 py-3 w-[18%]">
|
|
546
|
+
<Tooltip content={release.namespace}>
|
|
547
|
+
<span className="block truncate text-sm text-theme-text-secondary">{release.namespace}</span>
|
|
548
|
+
</Tooltip>
|
|
480
549
|
</td>
|
|
481
|
-
<td className="px-4 py-3 w-
|
|
550
|
+
<td className="px-4 py-3 w-[22%]">
|
|
482
551
|
<Tooltip content={`${release.chart}-${release.chartVersion}`}>
|
|
483
552
|
<span className="text-sm text-theme-text-secondary truncate block">
|
|
484
|
-
{
|
|
553
|
+
{release.chart}-{release.chartVersion}
|
|
485
554
|
</span>
|
|
486
555
|
</Tooltip>
|
|
487
556
|
</td>
|
|
@@ -494,7 +563,7 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
|
|
|
494
563
|
</Tooltip>
|
|
495
564
|
)}
|
|
496
565
|
</td>
|
|
497
|
-
<td className="px-4 py-3 w-
|
|
566
|
+
<td className="px-4 py-3 w-40">
|
|
498
567
|
{isHelmReleaseActionable(release.status) ? (
|
|
499
568
|
<Tooltip content="Click row to view rollback / history / logs and recover">
|
|
500
569
|
<span
|
|
@@ -515,7 +584,7 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
|
|
|
515
584
|
</span>
|
|
516
585
|
)}
|
|
517
586
|
</td>
|
|
518
|
-
<td className="px-4 py-3 w-
|
|
587
|
+
<td className="px-4 py-3 w-16">
|
|
519
588
|
<span className="text-sm text-theme-text-secondary">{release.revision}</span>
|
|
520
589
|
</td>
|
|
521
590
|
<td className="px-4 py-3 w-24">
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { History, Eye, GitCompare, Check, RotateCcw } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import type { HelmRevision } from '../../types'
|
|
4
|
+
import type { HelmOperation, HelmRevision } from '../../types'
|
|
5
5
|
import { getStatusColor, formatDate, formatAge } from './helm-utils'
|
|
6
6
|
import { SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
7
7
|
import { Tooltip } from '../ui/Tooltip'
|
|
@@ -9,12 +9,13 @@ import { Tooltip } from '../ui/Tooltip'
|
|
|
9
9
|
interface RevisionHistoryProps {
|
|
10
10
|
history: HelmRevision[]
|
|
11
11
|
currentRevision: number
|
|
12
|
+
operations?: HelmOperation[]
|
|
12
13
|
onViewRevision: (revision: number) => void
|
|
13
14
|
onCompare: (rev1: number, rev2: number) => void
|
|
14
15
|
onRollback?: (revision: number) => void
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
export function RevisionHistory({ history, currentRevision, onViewRevision, onCompare, onRollback }: RevisionHistoryProps) {
|
|
18
|
+
export function RevisionHistory({ history, currentRevision, operations = [], onViewRevision, onCompare, onRollback }: RevisionHistoryProps) {
|
|
18
19
|
const [selectedForCompare, setSelectedForCompare] = useState<number | null>(null)
|
|
19
20
|
|
|
20
21
|
const handleCompareClick = (revision: number) => {
|
|
@@ -63,6 +64,7 @@ export function RevisionHistory({ history, currentRevision, onViewRevision, onCo
|
|
|
63
64
|
{history.map((revision, index) => {
|
|
64
65
|
const isCurrent = revision.revision === currentRevision
|
|
65
66
|
const isSelectedForCompare = selectedForCompare === revision.revision
|
|
67
|
+
const annotations = operationAnnotationsForRevision(operations, revision.revision)
|
|
66
68
|
|
|
67
69
|
return (
|
|
68
70
|
<div
|
|
@@ -106,6 +108,11 @@ export function RevisionHistory({ history, currentRevision, onViewRevision, onCo
|
|
|
106
108
|
Current
|
|
107
109
|
</span>
|
|
108
110
|
)}
|
|
111
|
+
{annotations.map((annotation) => (
|
|
112
|
+
<span key={annotation.label} className={clsx('badge-sm', annotation.className)}>
|
|
113
|
+
{annotation.label}
|
|
114
|
+
</span>
|
|
115
|
+
))}
|
|
109
116
|
</div>
|
|
110
117
|
|
|
111
118
|
<div className="flex items-center gap-4 mt-1 text-xs text-theme-text-tertiary">
|
|
@@ -166,3 +173,40 @@ export function RevisionHistory({ history, currentRevision, onViewRevision, onCo
|
|
|
166
173
|
</div>
|
|
167
174
|
)
|
|
168
175
|
}
|
|
176
|
+
|
|
177
|
+
function operationAnnotationsForRevision(operations: HelmOperation[], revision: number): Array<{ label: string; className: string }> {
|
|
178
|
+
const annotations: Array<{ label: string; className: string }> = []
|
|
179
|
+
const seen = new Set<string>()
|
|
180
|
+
const add = (label: string, className: string) => {
|
|
181
|
+
if (seen.has(label)) return
|
|
182
|
+
seen.add(label)
|
|
183
|
+
annotations.push({ label, className })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const op of operations) {
|
|
187
|
+
if (op.failedRevision === revision) {
|
|
188
|
+
add('Failed upgrade', SEVERITY_BADGE.error)
|
|
189
|
+
}
|
|
190
|
+
if (op.rollbackRevision === revision) {
|
|
191
|
+
add('Rollback revision', SEVERITY_BADGE.warning)
|
|
192
|
+
}
|
|
193
|
+
if (op.revision === revision) {
|
|
194
|
+
switch (op.kind) {
|
|
195
|
+
case 'upgrade_failed':
|
|
196
|
+
add('Failed upgrade', SEVERITY_BADGE.error)
|
|
197
|
+
break
|
|
198
|
+
case 'release_failed':
|
|
199
|
+
add('Failed', SEVERITY_BADGE.error)
|
|
200
|
+
break
|
|
201
|
+
case 'rollback':
|
|
202
|
+
add('Rollback', SEVERITY_BADGE.warning)
|
|
203
|
+
break
|
|
204
|
+
case 'pending':
|
|
205
|
+
add('Pending', SEVERITY_BADGE.warning)
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return annotations
|
|
212
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
|
|
3
|
+
import { X, Plus, Trash2, Link2, AlertTriangle } from 'lucide-react'
|
|
4
|
+
import { clsx } from 'clsx'
|
|
5
|
+
import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo } from '../../api/client'
|
|
6
|
+
|
|
7
|
+
interface TrackChartSourceDialogProps {
|
|
8
|
+
open: boolean
|
|
9
|
+
onClose: () => void
|
|
10
|
+
/** Chart name of the release this was opened from, for the example prompt. */
|
|
11
|
+
chartName?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// TrackChartSourceDialog lets the user register an OCI chart-source prefix — the
|
|
15
|
+
// OCI analog of `helm repo add`. Helm doesn't persist the ref a release was
|
|
16
|
+
// installed from, so for charts published to an OCI registry (and not managed by
|
|
17
|
+
// GitOps) Radar can only track upgrades once the user declares where they live.
|
|
18
|
+
// Registering a registry/org prefix lets Radar probe "<prefix>/<chartName>".
|
|
19
|
+
export function TrackChartSourceDialog({ open, onClose, chartName }: TrackChartSourceDialogProps) {
|
|
20
|
+
const [value, setValue] = useState('')
|
|
21
|
+
const { data: sources } = useHelmOCISources()
|
|
22
|
+
const { data: clusterInfo } = useClusterInfo()
|
|
23
|
+
const addSource = useAddOCISource()
|
|
24
|
+
const removeSource = useRemoveOCISource()
|
|
25
|
+
|
|
26
|
+
// In-cluster Radar has no `helm registry login` store (the pod's HELM_CONFIG_HOME
|
|
27
|
+
// points at an empty /tmp), so private registries can't authenticate — only
|
|
28
|
+
// public charts can be tracked. Be honest about it rather than silently failing.
|
|
29
|
+
const inCluster = clusterInfo?.inCluster ?? false
|
|
30
|
+
|
|
31
|
+
const trimmed = value.trim()
|
|
32
|
+
const invalid = trimmed !== '' && !trimmed.startsWith('oci://')
|
|
33
|
+
|
|
34
|
+
const handleAdd = () => {
|
|
35
|
+
if (!trimmed || invalid) return
|
|
36
|
+
addSource.mutate(trimmed, { onSuccess: () => setValue('') })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<DialogPortal open={open} onClose={onClose} className="max-w-lg w-full">
|
|
41
|
+
<div className="flex items-start gap-3 p-4 border-b border-theme-border">
|
|
42
|
+
<div className="flex items-center justify-center w-10 h-10 rounded-full shrink-0 bg-theme-hover">
|
|
43
|
+
<Link2 className="w-5 h-5 text-theme-text-secondary" />
|
|
44
|
+
</div>
|
|
45
|
+
<div className="flex-1 min-w-0">
|
|
46
|
+
<h3 className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
|
|
47
|
+
<p className="text-sm text-theme-text-secondary mt-1">
|
|
48
|
+
Helm doesn't record where a chart was installed from. Register your OCI
|
|
49
|
+
registry prefix and Radar will check it for newer versions of your charts.
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
<button
|
|
53
|
+
onClick={onClose}
|
|
54
|
+
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
55
|
+
>
|
|
56
|
+
<X className="w-5 h-5" />
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div className="p-4 space-y-4">
|
|
61
|
+
<div>
|
|
62
|
+
<label className="block text-sm font-medium text-theme-text-secondary mb-2">
|
|
63
|
+
OCI registry prefix
|
|
64
|
+
</label>
|
|
65
|
+
<div className="flex gap-2">
|
|
66
|
+
<input
|
|
67
|
+
type="text"
|
|
68
|
+
value={value}
|
|
69
|
+
onChange={(e) => setValue(e.target.value)}
|
|
70
|
+
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
|
71
|
+
placeholder="oci://ghcr.io/myorg/charts"
|
|
72
|
+
aria-invalid={invalid ? true : undefined}
|
|
73
|
+
className={clsx(
|
|
74
|
+
'flex-1 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',
|
|
75
|
+
invalid ? 'border-red-500/60 focus:ring-red-500' : 'border-theme-border-light focus:ring-accent',
|
|
76
|
+
)}
|
|
77
|
+
/>
|
|
78
|
+
<button
|
|
79
|
+
onClick={handleAdd}
|
|
80
|
+
disabled={!trimmed || invalid || addSource.isPending}
|
|
81
|
+
className="btn-brand px-3 py-2 text-sm inline-flex items-center gap-1 disabled:opacity-50 disabled:pointer-events-none"
|
|
82
|
+
>
|
|
83
|
+
<Plus className="w-4 h-4" />
|
|
84
|
+
Add
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
88
|
+
{invalid
|
|
89
|
+
? 'Must be an oci:// reference.'
|
|
90
|
+
: chartName
|
|
91
|
+
? `Radar will look for "${chartName}" under this prefix (and any others below).`
|
|
92
|
+
: 'Radar probes <prefix>/<chartName> for each untracked release.'}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{sources && sources.length > 0 && (
|
|
97
|
+
<div>
|
|
98
|
+
<p className="text-xs font-medium text-theme-text-tertiary uppercase tracking-wide mb-2">
|
|
99
|
+
Registered sources
|
|
100
|
+
</p>
|
|
101
|
+
<ul className="space-y-1">
|
|
102
|
+
{sources.map((src) => (
|
|
103
|
+
<li
|
|
104
|
+
key={src}
|
|
105
|
+
className="flex items-center justify-between gap-2 px-3 py-2 bg-theme-elevated rounded-lg"
|
|
106
|
+
>
|
|
107
|
+
<span className="text-sm text-theme-text-primary font-mono truncate">{src}</span>
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => removeSource.mutate(src)}
|
|
110
|
+
disabled={removeSource.isPending}
|
|
111
|
+
className="p-1 text-theme-text-secondary hover:text-red-400 hover:bg-red-500/10 rounded disabled:opacity-50"
|
|
112
|
+
aria-label={`Remove ${src}`}
|
|
113
|
+
>
|
|
114
|
+
<Trash2 className="w-4 h-4" />
|
|
115
|
+
</button>
|
|
116
|
+
</li>
|
|
117
|
+
))}
|
|
118
|
+
</ul>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{inCluster ? (
|
|
123
|
+
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
|
|
124
|
+
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
|
125
|
+
<span>
|
|
126
|
+
Radar is running in-cluster, where it has no{' '}
|
|
127
|
+
<span className="font-mono">helm registry login</span> credentials — only{' '}
|
|
128
|
+
<strong>public</strong> charts can be tracked. Private-registry support for in-cluster
|
|
129
|
+
Radar isn't available yet.
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
) : (
|
|
133
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
134
|
+
Credentials are reused from your <span className="font-mono">helm registry login</span>.
|
|
135
|
+
Radar stores no registry secrets.
|
|
136
|
+
</p>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
</DialogPortal>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
@@ -100,7 +100,7 @@ export function TrafficSummary({ data, onNavigate }: TrafficSummaryProps) {
|
|
|
100
100
|
<div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
|
|
101
101
|
<div className="flex items-center gap-2">
|
|
102
102
|
<Activity className="w-4 h-4 text-theme-text-tertiary" />
|
|
103
|
-
<span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">Traffic</span>
|
|
103
|
+
<span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">Live Traffic</span>
|
|
104
104
|
</div>
|
|
105
105
|
{hasFlows && (
|
|
106
106
|
<span className="text-[11px] text-theme-text-tertiary">
|
|
@@ -145,7 +145,7 @@ export function TrafficSummary({ data, onNavigate }: TrafficSummaryProps) {
|
|
|
145
145
|
</div>
|
|
146
146
|
|
|
147
147
|
<div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-end gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-theme-text-secondary group-hover:text-theme-text-primary transition-colors">
|
|
148
|
-
Open Traffic
|
|
148
|
+
Open Live Traffic
|
|
149
149
|
<ArrowRight className="w-3.5 h-3.5 transition-transform group-hover:translate-x-0.5" />
|
|
150
150
|
</div>
|
|
151
151
|
</div>
|
|
@@ -126,7 +126,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
name: 'get_changes',
|
|
129
|
-
desc: 'Recent resource creates, updates, and deletes from the
|
|
129
|
+
desc: 'Recent resource creates, updates, and deletes from the Kubernetes timeline. Helm release history is separate; use list_helm_releases or get_helm_release include=history,operations for failed upgrades and rollbacks.',
|
|
130
130
|
params: [
|
|
131
131
|
{ arg: 'namespace', desc: 'filter to a specific namespace' },
|
|
132
132
|
{ arg: 'kind', desc: 'filter to a resource kind (e.g. Deployment)' },
|
|
@@ -147,16 +147,16 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
|
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
149
|
name: 'list_helm_releases',
|
|
150
|
-
desc: 'All Helm releases
|
|
150
|
+
desc: 'All Helm releases with status, resource health, storage namespace, Flux ownership, current lastOperation, and capped operation trails for failed upgrades, rollbacks, or stuck pending operations.',
|
|
151
151
|
params: [{ arg: 'namespace', desc: 'filter to a specific namespace' }],
|
|
152
152
|
},
|
|
153
153
|
{
|
|
154
154
|
name: 'get_helm_release',
|
|
155
|
-
desc: 'Detailed Helm release info with owned resources
|
|
155
|
+
desc: 'Detailed Helm release info with owned resources, health, Flux ownership, and current lastOperation; include history and operations for the full revision trail.',
|
|
156
156
|
params: [
|
|
157
|
-
{ arg: 'namespace', required: true, desc: '
|
|
157
|
+
{ arg: 'namespace', required: true, desc: 'Helm storage namespace; use storageNamespace from list_helm_releases when present' },
|
|
158
158
|
{ arg: 'name', required: true, desc: 'release name' },
|
|
159
|
-
{ arg: 'include', desc: 'values, history, diff' },
|
|
159
|
+
{ arg: 'include', desc: 'values, history, operations, diff' },
|
|
160
160
|
{ arg: 'diff_revision_1', desc: 'first revision for diff' },
|
|
161
161
|
{ arg: 'diff_revision_2', desc: 'second revision for diff (defaults to current)' },
|
|
162
162
|
],
|
|
@@ -45,7 +45,7 @@ const NAV_ITEMS: NavItemDef[] = [
|
|
|
45
45
|
{ view: 'topology', icon: Network, label: 'Topology' },
|
|
46
46
|
{ view: 'applications', icon: Boxes, label: 'Applications' },
|
|
47
47
|
{ view: 'timeline', icon: Clock, label: 'Timeline' },
|
|
48
|
-
{ view: 'traffic', icon: Activity, label: 'Traffic' },
|
|
48
|
+
{ view: 'traffic', icon: Activity, label: 'Live Traffic' },
|
|
49
49
|
{ view: 'helm', icon: Package, label: 'Helm' },
|
|
50
50
|
{ view: 'gitops', icon: GitBranch, label: 'GitOps' },
|
|
51
51
|
{ view: 'checks', icon: ShieldCheck, label: 'Checks' },
|
|
@@ -91,7 +91,7 @@ const VIEW_ENTRIES: { view: MainView; label: string; icon: React.ComponentType<{
|
|
|
91
91
|
{ view: 'timeline', label: 'Timeline', icon: Clock, shortcut: 'g l' },
|
|
92
92
|
{ view: 'helm', label: 'Helm', icon: Package, shortcut: 'g m' },
|
|
93
93
|
{ view: 'gitops', label: 'GitOps', icon: GitBranch, shortcut: 'g o' },
|
|
94
|
-
{ view: 'traffic', label: 'Traffic', icon: Activity, shortcut: 'g f' },
|
|
94
|
+
{ view: 'traffic', label: 'Live Traffic', icon: Activity, shortcut: 'g f' },
|
|
95
95
|
{ view: 'checks', label: 'Checks', icon: ShieldCheck, shortcut: 'g u' },
|
|
96
96
|
{ view: 'cost', label: 'Cost', icon: DollarSign, shortcut: 'g c' },
|
|
97
97
|
]
|
|
@@ -62,6 +62,22 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
62
62
|
// Track if SSE has started delivering connection_state events
|
|
63
63
|
// Once SSE is active, it becomes the authoritative source for connection state
|
|
64
64
|
const sseActiveRef = useRef(false)
|
|
65
|
+
// Track whether we've reached 'connected' at least once. Distinguishes the
|
|
66
|
+
// initial connect (bootstrap queries already fetched while 'connecting') from
|
|
67
|
+
// a reconnect after a drop (cache may be stale across the gap).
|
|
68
|
+
const hasConnectedRef = useRef(false)
|
|
69
|
+
// Whether the QueryClient already held data when this provider mounted. A host
|
|
70
|
+
// can share one client across cluster-scoped RadarApp mounts (see RadarApp's
|
|
71
|
+
// `queryClient` prop); that client may carry another cluster's data under
|
|
72
|
+
// identical keys, so a warm-at-mount cache must be fully refreshed on first
|
|
73
|
+
// connect. A cold cache (standalone, or a per-cluster remount) takes the cheap
|
|
74
|
+
// error-only path. Snapshot synchronously before this provider's own query
|
|
75
|
+
// registers — ConnectionProvider is the outermost provider, so a fresh client
|
|
76
|
+
// is genuinely empty here.
|
|
77
|
+
const cacheWarmAtMountRef = useRef<boolean | null>(null)
|
|
78
|
+
if (cacheWarmAtMountRef.current === null) {
|
|
79
|
+
cacheWarmAtMountRef.current = queryClient.getQueryCache().getAll().length > 0
|
|
80
|
+
}
|
|
65
81
|
|
|
66
82
|
// Fetch initial connection status
|
|
67
83
|
// Poll while connecting to get progress updates (SSE not established yet)
|
|
@@ -143,9 +159,20 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
143
159
|
return status
|
|
144
160
|
})
|
|
145
161
|
|
|
146
|
-
// If we just connected, invalidate queries to fetch fresh data
|
|
147
162
|
if (status.state === 'connected') {
|
|
148
|
-
|
|
163
|
+
const firstConnect = !hasConnectedRef.current
|
|
164
|
+
hasConnectedRef.current = true
|
|
165
|
+
// A reconnect after a drop (cache stale across the gap), or a first connect
|
|
166
|
+
// onto a client that already carried data at mount (shared across clusters),
|
|
167
|
+
// refreshes the whole cache. A clean first connect only needs to recover the
|
|
168
|
+
// bootstrap queries that 503'd while the cluster was still 'connecting'
|
|
169
|
+
// (status === 'error'); the rest already fetched fresh during 'connecting',
|
|
170
|
+
// so re-fetching the whole cache there would double-load every endpoint.
|
|
171
|
+
if (!firstConnect || cacheWarmAtMountRef.current) {
|
|
172
|
+
queryClient.invalidateQueries()
|
|
173
|
+
} else {
|
|
174
|
+
queryClient.invalidateQueries({ predicate: (q) => q.state.status === 'error' })
|
|
175
|
+
}
|
|
149
176
|
}
|
|
150
177
|
}, [queryClient])
|
|
151
178
|
|