@skyhook-io/radar-app 1.8.1 → 1.8.3
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 +167 -56
- package/src/RadarApp.tsx +18 -1
- package/src/api/client.ts +173 -6
- package/src/components/NamespaceSwitcher.tsx +52 -30
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/gitops/GitOpsView.tsx +1 -10
- package/src/components/helm/HelmReleaseDrawer.tsx +802 -44
- package/src/components/helm/HelmView.tsx +85 -16
- package/src/components/helm/ManifestDiffViewer.tsx +15 -4
- package/src/components/helm/OwnedResources.tsx +14 -50
- package/src/components/helm/RevisionHistory.tsx +50 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
- package/src/components/helm/ValuesViewer.tsx +41 -11
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +10 -10
- package/src/components/nav/PrimaryNavRail.tsx +1 -1
- package/src/components/portforward/PortForwardButton.tsx +69 -25
- package/src/components/portforward/PortForwardManager.tsx +18 -4
- package/src/components/resources/ResourcesView.tsx +42 -1
- package/src/components/resources/renderers/PodRenderer.tsx +7 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
- package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +5 -10
- package/src/components/ui/command-items.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +52 -7
- package/src/context/ConnectionContext.tsx +29 -2
- package/src/contexts/CapabilitiesContext.tsx +8 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/main.tsx +5 -3
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +5 -3
|
@@ -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">
|
|
@@ -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,6 +69,15 @@ export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onCl
|
|
|
67
69
|
)
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
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
|
+
|
|
70
81
|
function DiffLine({ line }: { line: string }) {
|
|
71
82
|
const isAddition = line.startsWith('+') && !line.startsWith('+++')
|
|
72
83
|
const isRemoval = line.startsWith('-') && !line.startsWith('---')
|
|
@@ -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
|
}
|
|
@@ -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, revision.status)
|
|
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,44 @@ export function RevisionHistory({ history, currentRevision, onViewRevision, onCo
|
|
|
166
173
|
</div>
|
|
167
174
|
)
|
|
168
175
|
}
|
|
176
|
+
|
|
177
|
+
function operationAnnotationsForRevision(operations: HelmOperation[], revision: number, revisionStatus: string): 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
|
+
const addFailure = (label: string) => {
|
|
186
|
+
if (revisionStatus.toLowerCase() === 'failed') return
|
|
187
|
+
add(label, SEVERITY_BADGE.error)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const op of operations) {
|
|
191
|
+
if (op.failedRevision === revision) {
|
|
192
|
+
addFailure('Failed upgrade')
|
|
193
|
+
}
|
|
194
|
+
if (op.rollbackRevision === revision) {
|
|
195
|
+
add('Rollback revision', SEVERITY_BADGE.warning)
|
|
196
|
+
}
|
|
197
|
+
if (op.revision === revision) {
|
|
198
|
+
switch (op.kind) {
|
|
199
|
+
case 'upgrade_failed':
|
|
200
|
+
addFailure('Failed upgrade')
|
|
201
|
+
break
|
|
202
|
+
case 'release_failed':
|
|
203
|
+
addFailure('Failed')
|
|
204
|
+
break
|
|
205
|
+
case 'rollback':
|
|
206
|
+
add('Rollback', SEVERITY_BADGE.warning)
|
|
207
|
+
break
|
|
208
|
+
case 'pending':
|
|
209
|
+
add('Pending', SEVERITY_BADGE.warning)
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return annotations
|
|
216
|
+
}
|
|
@@ -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
|
+
}
|