@skyhook-io/radar-app 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/App.tsx +214 -39
- package/src/api/client.ts +235 -3
- package/src/components/NamespaceSwitcher.tsx +298 -0
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/index.ts +1 -1
- package/src/components/gitops/GitOpsView.tsx +2441 -0
- package/src/components/gitops/RollbackDialog.tsx +107 -0
- package/src/components/gitops/SyncOptionsDialog.tsx +144 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +20 -3
- package/src/components/helm/HelmView.tsx +9 -1
- package/src/components/helm/OwnedResources.tsx +2 -2
- package/src/components/home/GitOpsControllersCard.tsx +108 -0
- package/src/components/home/HomeView.tsx +9 -1
- package/src/components/portforward/PortForwardManager.tsx +135 -109
- package/src/components/resources/ResourcesView.tsx +27 -2
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/timeline/TimelineSwimlanes.tsx +20 -6
- package/src/components/ui/CommandPalette.tsx +6 -4
- package/src/components/ui/ShortcutHelpOverlay.tsx +2 -1
- package/src/components/ui/UpdateNotification.tsx +36 -19
- package/src/components/workload/WorkloadView.tsx +126 -10
- package/src/types.ts +2 -0
- package/src/utils/navigation.ts +14 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -446
|
@@ -42,11 +42,44 @@ interface PortForwardSession {
|
|
|
42
42
|
localPort: number
|
|
43
43
|
listenAddress: string
|
|
44
44
|
serviceName?: string
|
|
45
|
+
servicePort?: number
|
|
46
|
+
scheme?: 'http' | 'https'
|
|
45
47
|
startedAt: string
|
|
46
48
|
status: 'running' | 'stopped' | 'error'
|
|
47
49
|
error?: string
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
function sessionUrl(session: PortForwardSession): string {
|
|
53
|
+
return `${session.scheme || 'http'}://localhost:${session.localPort}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatPortLabel(session: PortForwardSession): string {
|
|
57
|
+
if (session.servicePort && session.servicePort !== session.podPort) {
|
|
58
|
+
return `${session.servicePort} → ${session.podPort}`
|
|
59
|
+
}
|
|
60
|
+
return String(session.podPort)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build the recreate request body for toggle-listen / change-port flows.
|
|
64
|
+
// When the original session was service-resolved, the recreate must also go
|
|
65
|
+
// through the service path (servicePort + serviceName). Sending podName+podPort
|
|
66
|
+
// would skip resolution and validate against the pod's declared containerPorts,
|
|
67
|
+
// which can fail even though the original session worked: services can route to
|
|
68
|
+
// any port the container actually listens on, regardless of containerPort
|
|
69
|
+
// declarations. Going through service also re-resolves to a currently-running
|
|
70
|
+
// pod if the original has since been replaced.
|
|
71
|
+
function buildRecreateBody(session: PortForwardSession, overrides: { localPort: number; listenAddress: string }) {
|
|
72
|
+
const base = {
|
|
73
|
+
namespace: session.namespace,
|
|
74
|
+
localPort: overrides.localPort,
|
|
75
|
+
listenAddress: overrides.listenAddress,
|
|
76
|
+
}
|
|
77
|
+
if (session.serviceName && session.servicePort) {
|
|
78
|
+
return { ...base, serviceName: session.serviceName, podPort: session.servicePort }
|
|
79
|
+
}
|
|
80
|
+
return { ...base, podName: session.podName, podPort: session.podPort }
|
|
81
|
+
}
|
|
82
|
+
|
|
50
83
|
// --- Shared query ------------------------------------------------------------
|
|
51
84
|
|
|
52
85
|
function usePortForwardQuery() {
|
|
@@ -430,14 +463,7 @@ export function PortForwardPanel() {
|
|
|
430
463
|
const res = await fetch(apiUrl('/portforwards'), {
|
|
431
464
|
method: 'POST',
|
|
432
465
|
headers: { 'Content-Type': 'application/json' },
|
|
433
|
-
body: JSON.stringify({
|
|
434
|
-
namespace: session.namespace,
|
|
435
|
-
podName: session.podName || undefined,
|
|
436
|
-
serviceName: session.serviceName || undefined,
|
|
437
|
-
podPort: session.podPort,
|
|
438
|
-
localPort: session.localPort,
|
|
439
|
-
listenAddress: newAddress,
|
|
440
|
-
}),
|
|
466
|
+
body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: newAddress })),
|
|
441
467
|
})
|
|
442
468
|
if (!res.ok) {
|
|
443
469
|
const error = await res.json().catch(() => ({}))
|
|
@@ -484,14 +510,7 @@ export function PortForwardPanel() {
|
|
|
484
510
|
const res = await fetch(apiUrl('/portforwards'), {
|
|
485
511
|
method: 'POST',
|
|
486
512
|
headers: { 'Content-Type': 'application/json' },
|
|
487
|
-
body: JSON.stringify({
|
|
488
|
-
namespace: session.namespace,
|
|
489
|
-
podName: session.podName || undefined,
|
|
490
|
-
serviceName: session.serviceName || undefined,
|
|
491
|
-
podPort: session.podPort,
|
|
492
|
-
localPort: newPort,
|
|
493
|
-
listenAddress: session.listenAddress,
|
|
494
|
-
}),
|
|
513
|
+
body: JSON.stringify(buildRecreateBody(session, { localPort: newPort, listenAddress: session.listenAddress })),
|
|
495
514
|
})
|
|
496
515
|
if (!res.ok) {
|
|
497
516
|
const error = await res.json().catch(() => ({}))
|
|
@@ -521,7 +540,7 @@ export function PortForwardPanel() {
|
|
|
521
540
|
async (session: PortForwardSession) => {
|
|
522
541
|
commitInteraction()
|
|
523
542
|
try {
|
|
524
|
-
await navigator.clipboard.writeText(
|
|
543
|
+
await navigator.clipboard.writeText(sessionUrl(session))
|
|
525
544
|
} catch (err) {
|
|
526
545
|
// Clipboard API can reject in non-secure contexts, denied permissions, or
|
|
527
546
|
// when the document isn't focused. Surface the failure — the checkmark
|
|
@@ -544,7 +563,7 @@ export function PortForwardPanel() {
|
|
|
544
563
|
const handleOpenUrl = useCallback(
|
|
545
564
|
(session: PortForwardSession) => {
|
|
546
565
|
commitInteraction()
|
|
547
|
-
openExternal(
|
|
566
|
+
openExternal(sessionUrl(session))
|
|
548
567
|
},
|
|
549
568
|
[commitInteraction]
|
|
550
569
|
)
|
|
@@ -626,7 +645,7 @@ export function PortForwardPanel() {
|
|
|
626
645
|
</div>
|
|
627
646
|
|
|
628
647
|
{/* Sessions list */}
|
|
629
|
-
<div className="max-h-
|
|
648
|
+
<div className="max-h-[28rem] overflow-y-auto">
|
|
630
649
|
{isQueryError ? (
|
|
631
650
|
<div className="p-3 text-xs bg-red-500/10 border-b border-theme-border">
|
|
632
651
|
<div className={clsx('badge-sm mb-1 inline-block', SEVERITY_BADGE.error)}>
|
|
@@ -651,37 +670,84 @@ export function PortForwardPanel() {
|
|
|
651
670
|
<div
|
|
652
671
|
key={session.id}
|
|
653
672
|
className={clsx(
|
|
654
|
-
'p-3',
|
|
673
|
+
'p-3 space-y-1',
|
|
655
674
|
session.status === 'error' ? 'bg-red-500/10' : 'hover:bg-theme-elevated'
|
|
656
675
|
)}
|
|
657
676
|
>
|
|
677
|
+
{/* Row 1: status dot + name | stop button */}
|
|
658
678
|
<div className="flex items-start justify-between gap-2">
|
|
659
|
-
<div className="flex-
|
|
660
|
-
<
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
|
|
665
|
-
)}
|
|
666
|
-
/>
|
|
667
|
-
<span className="text-sm text-theme-text-primary font-medium truncate">
|
|
668
|
-
{session.serviceName || session.podName}
|
|
669
|
-
</span>
|
|
670
|
-
{session.status === 'error' && (
|
|
671
|
-
<span className={clsx('badge-sm', SEVERITY_BADGE.error)}>Failed</span>
|
|
679
|
+
<div className="flex items-start gap-2 min-w-0 flex-1">
|
|
680
|
+
<span
|
|
681
|
+
className={clsx(
|
|
682
|
+
'w-2 h-2 rounded-full shrink-0 mt-[7px]',
|
|
683
|
+
session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
|
|
672
684
|
)}
|
|
673
|
-
|
|
674
|
-
<
|
|
675
|
-
{session.
|
|
676
|
-
</
|
|
677
|
-
{session.status === 'error' &&
|
|
678
|
-
<
|
|
679
|
-
{session.error}
|
|
680
|
-
</div>
|
|
685
|
+
/>
|
|
686
|
+
<span className="text-sm text-theme-text-primary font-medium break-all line-clamp-2">
|
|
687
|
+
{session.serviceName || session.podName}
|
|
688
|
+
</span>
|
|
689
|
+
{session.status === 'error' && (
|
|
690
|
+
<span className={clsx('badge-sm shrink-0', SEVERITY_BADGE.error)}>Failed</span>
|
|
681
691
|
)}
|
|
692
|
+
</div>
|
|
693
|
+
<div className="flex items-center gap-0.5 shrink-0">
|
|
682
694
|
{session.status === 'running' && (
|
|
683
|
-
<
|
|
684
|
-
{
|
|
695
|
+
<Tooltip
|
|
696
|
+
content={session.listenAddress === '0.0.0.0' ? 'Switch to localhost only' : 'Allow access from other machines'}
|
|
697
|
+
delay={300} position="bottom" disabled={!isPanelOpen}
|
|
698
|
+
>
|
|
699
|
+
<button
|
|
700
|
+
onClick={() => toggleListenAddress(session)}
|
|
701
|
+
disabled={togglingId === session.id || changingPortId === session.id}
|
|
702
|
+
className={clsx(
|
|
703
|
+
'flex items-center justify-center p-1.5 rounded transition-colors',
|
|
704
|
+
session.listenAddress === '0.0.0.0'
|
|
705
|
+
? `${SEVERITY_BADGE.warning} hover:bg-amber-500/30`
|
|
706
|
+
: 'text-theme-text-disabled hover:text-theme-text-primary hover:bg-theme-hover'
|
|
707
|
+
)}
|
|
708
|
+
>
|
|
709
|
+
{togglingId === session.id ? (
|
|
710
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
711
|
+
) : session.listenAddress === '0.0.0.0' ? (
|
|
712
|
+
<Globe className="w-3.5 h-3.5" />
|
|
713
|
+
) : (
|
|
714
|
+
<Monitor className="w-3.5 h-3.5" />
|
|
715
|
+
)}
|
|
716
|
+
</button>
|
|
717
|
+
</Tooltip>
|
|
718
|
+
)}
|
|
719
|
+
<Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
720
|
+
<button
|
|
721
|
+
onClick={() => {
|
|
722
|
+
commitInteraction()
|
|
723
|
+
stopPortForward(session.id)
|
|
724
|
+
}}
|
|
725
|
+
disabled={stoppingIds.has(session.id)}
|
|
726
|
+
className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
|
|
727
|
+
>
|
|
728
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
729
|
+
</button>
|
|
730
|
+
</Tooltip>
|
|
731
|
+
</div>
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
{/* Row 2: namespace · port translation */}
|
|
735
|
+
<div className="text-xs text-theme-text-disabled">
|
|
736
|
+
{session.namespace} · {formatPortLabel(session)}
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
{/* Row 2.5: error message */}
|
|
740
|
+
{session.status === 'error' && session.error && (
|
|
741
|
+
<div className="text-xs text-red-400 bg-red-500/10 px-2 py-1 rounded">
|
|
742
|
+
{session.error}
|
|
743
|
+
</div>
|
|
744
|
+
)}
|
|
745
|
+
|
|
746
|
+
{/* Row 3: URL (+ optional toggle) | copy + open */}
|
|
747
|
+
{session.status === 'running' && (
|
|
748
|
+
<div className="pt-0.5 flex items-center justify-between gap-2">
|
|
749
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
750
|
+
{editingPortId === session.id ? (
|
|
685
751
|
<div className="flex items-center text-xs bg-theme-base rounded text-accent-text font-mono">
|
|
686
752
|
<span className="pl-2 py-1 text-theme-text-disabled select-none">
|
|
687
753
|
{session.listenAddress === '0.0.0.0' ? '0.0.0.0' : 'localhost'}:
|
|
@@ -724,6 +790,7 @@ export function PortForwardPanel() {
|
|
|
724
790
|
/>
|
|
725
791
|
</div>
|
|
726
792
|
) : (
|
|
793
|
+
<>
|
|
727
794
|
<Tooltip content="Click to change local port" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
728
795
|
<code
|
|
729
796
|
className={clsx(
|
|
@@ -747,74 +814,33 @@ export function PortForwardPanel() {
|
|
|
747
814
|
<PenLine className="w-3 h-3 text-theme-text-disabled opacity-0 group-hover/port:opacity-100 transition-opacity" />
|
|
748
815
|
</code>
|
|
749
816
|
</Tooltip>
|
|
817
|
+
</>
|
|
818
|
+
)}
|
|
819
|
+
</div>
|
|
820
|
+
<div className="flex items-center gap-0.5 shrink-0">
|
|
821
|
+
<Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
822
|
+
<button
|
|
823
|
+
onClick={() => handleCopyUrl(session)}
|
|
824
|
+
className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
825
|
+
>
|
|
826
|
+
{copiedId === session.id ? (
|
|
827
|
+
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
828
|
+
) : (
|
|
829
|
+
<Copy className="w-3.5 h-3.5" />
|
|
750
830
|
)}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
: 'bg-theme-elevated text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
763
|
-
)}
|
|
764
|
-
>
|
|
765
|
-
{togglingId === session.id ? (
|
|
766
|
-
<Loader2 className="w-3 h-3 animate-spin" />
|
|
767
|
-
) : session.listenAddress === '0.0.0.0' ? (
|
|
768
|
-
<Globe className="w-3 h-3" />
|
|
769
|
-
) : (
|
|
770
|
-
<Monitor className="w-3 h-3" />
|
|
771
|
-
)}
|
|
772
|
-
{session.listenAddress === '0.0.0.0' ? 'network' : 'local'}
|
|
773
|
-
</button>
|
|
774
|
-
</Tooltip>
|
|
775
|
-
</div>
|
|
776
|
-
)}
|
|
777
|
-
</div>
|
|
778
|
-
|
|
779
|
-
<div className="flex items-center gap-1 shrink-0">
|
|
780
|
-
{session.status === 'running' && (
|
|
781
|
-
<>
|
|
782
|
-
<Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
783
|
-
<button
|
|
784
|
-
onClick={() => handleCopyUrl(session)}
|
|
785
|
-
className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
786
|
-
>
|
|
787
|
-
{copiedId === session.id ? (
|
|
788
|
-
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
789
|
-
) : (
|
|
790
|
-
<Copy className="w-3.5 h-3.5" />
|
|
791
|
-
)}
|
|
792
|
-
</button>
|
|
793
|
-
</Tooltip>
|
|
794
|
-
<Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
795
|
-
<button
|
|
796
|
-
onClick={() => handleOpenUrl(session)}
|
|
797
|
-
className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
798
|
-
>
|
|
799
|
-
<ExternalLink className="w-3.5 h-3.5" />
|
|
800
|
-
</button>
|
|
801
|
-
</Tooltip>
|
|
802
|
-
</>
|
|
803
|
-
)}
|
|
804
|
-
<Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
805
|
-
<button
|
|
806
|
-
onClick={() => {
|
|
807
|
-
commitInteraction()
|
|
808
|
-
stopPortForward(session.id)
|
|
809
|
-
}}
|
|
810
|
-
disabled={stoppingIds.has(session.id)}
|
|
811
|
-
className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
|
|
812
|
-
>
|
|
813
|
-
<Trash2 className="w-3.5 h-3.5" />
|
|
814
|
-
</button>
|
|
815
|
-
</Tooltip>
|
|
831
|
+
</button>
|
|
832
|
+
</Tooltip>
|
|
833
|
+
<Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
834
|
+
<button
|
|
835
|
+
onClick={() => handleOpenUrl(session)}
|
|
836
|
+
className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
837
|
+
>
|
|
838
|
+
<ExternalLink className="w-3.5 h-3.5" />
|
|
839
|
+
</button>
|
|
840
|
+
</Tooltip>
|
|
841
|
+
</div>
|
|
816
842
|
</div>
|
|
817
|
-
|
|
843
|
+
)}
|
|
818
844
|
</div>
|
|
819
845
|
))}
|
|
820
846
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useMemo, useCallback, useEffect } from 'react'
|
|
2
2
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
3
3
|
import { useQuery } from '@tanstack/react-query'
|
|
4
|
-
import { ApiError, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics } from '../../api/client'
|
|
4
|
+
import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics } from '../../api/client'
|
|
5
5
|
import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
|
|
6
6
|
import { useAPIResources } from '../../api/apiResources'
|
|
7
7
|
import { initNavigationMap } from '@skyhook-io/k8s-ui'
|
|
@@ -52,7 +52,17 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
52
52
|
queryFn: async () => {
|
|
53
53
|
const params = new URLSearchParams()
|
|
54
54
|
if (namespaces.length > 0) params.set('namespaces', namespacesParam)
|
|
55
|
-
|
|
55
|
+
const startedAt = performance.now()
|
|
56
|
+
debugNamespaceLog('resources:counts-fetch-start', { namespaces, params: params.toString() })
|
|
57
|
+
try {
|
|
58
|
+
return await fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
|
|
59
|
+
} finally {
|
|
60
|
+
debugNamespaceLog('resources:counts-fetch-end', {
|
|
61
|
+
namespaces,
|
|
62
|
+
params: params.toString(),
|
|
63
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
56
66
|
},
|
|
57
67
|
staleTime: 10000,
|
|
58
68
|
refetchInterval: 60000, // Safety net — SSE k8s_event drives near-real-time invalidation
|
|
@@ -75,10 +85,25 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
75
85
|
const params = new URLSearchParams()
|
|
76
86
|
if (namespaces.length > 0) params.set('namespaces', namespacesParam)
|
|
77
87
|
if (isSelectedCrd && selectedKind.group) params.set('group', selectedKind.group)
|
|
88
|
+
const startedAt = performance.now()
|
|
89
|
+
debugNamespaceLog('resources:selected-kind-fetch-start', {
|
|
90
|
+
kind: selectedKind.name,
|
|
91
|
+
group: isSelectedCrd ? selectedKind.group : '',
|
|
92
|
+
namespaces,
|
|
93
|
+
params: params.toString(),
|
|
94
|
+
})
|
|
78
95
|
const res = await fetch(apiUrl(`/resources/${selectedKind.name}?${params}`), {
|
|
79
96
|
credentials: getCredentialsMode(),
|
|
80
97
|
headers: getAuthHeaders(),
|
|
81
98
|
})
|
|
99
|
+
debugNamespaceLog('resources:selected-kind-fetch-response', {
|
|
100
|
+
kind: selectedKind.name,
|
|
101
|
+
group: isSelectedCrd ? selectedKind.group : '',
|
|
102
|
+
namespaces,
|
|
103
|
+
params: params.toString(),
|
|
104
|
+
status: res.status,
|
|
105
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
106
|
+
})
|
|
82
107
|
if (!res.ok) {
|
|
83
108
|
const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
|
|
84
109
|
throw new ApiError(errorData.error || `Failed to fetch ${selectedKind.name}`, res.status, errorData)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Re-export all resource utilities from the shared @skyhook-io/k8s-ui package.
|
|
2
2
|
export * from '@skyhook-io/k8s-ui/components/resources/resource-utils'
|
|
3
3
|
|
|
4
|
-
//
|
|
4
|
+
// formatBytes lives in utils/format but is re-exported here so consumers
|
|
5
|
+
// can import it from the same module as the rest of the resource utilities.
|
|
5
6
|
export { formatBytes } from '@skyhook-io/k8s-ui/utils/format'
|
|
@@ -26,8 +26,9 @@ import {
|
|
|
26
26
|
import { useHasLimitedAccess } from '../../contexts/CapabilitiesContext'
|
|
27
27
|
import type { TimelineEvent, Topology } from '../../types'
|
|
28
28
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
29
|
-
import { kindToPlural } from '../../utils/navigation'
|
|
30
|
-
import { PaneLoader, pluralize } from '@skyhook-io/k8s-ui'
|
|
29
|
+
import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
|
|
30
|
+
import { PaneLoader, pluralize, gitOpsRouteForKind } from '@skyhook-io/k8s-ui'
|
|
31
|
+
import { useNavigate } from 'react-router-dom'
|
|
31
32
|
import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../../types'
|
|
32
33
|
import { DiffViewer } from './DiffViewer'
|
|
33
34
|
import { getOperationColor, getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
|
|
@@ -174,7 +175,20 @@ function calculateInterestingnessWithBreakdown(lane: ResourceLane): ScoreBreakdo
|
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces }: TimelineSwimlanesProps) {
|
|
178
|
+
const navigate = useNavigate()
|
|
177
179
|
const hasLimitedAccess = useHasLimitedAccess()
|
|
180
|
+
// Timeline lane labels for GitOps CRs (Application/Kustomization/HelmRelease)
|
|
181
|
+
// deep-link to GitOps detail rather than the resource drawer — the lane is
|
|
182
|
+
// already telling the user "this controller had changes/events"; the GitOps
|
|
183
|
+
// tab is the right place to investigate further.
|
|
184
|
+
const handleLaneOpen = useCallback((kind: string, namespace: string, name: string, group?: string) => {
|
|
185
|
+
const gitOpsPath = gitOpsRouteForKind(kind, namespace, name)
|
|
186
|
+
if (gitOpsPath) {
|
|
187
|
+
navigate(gitOpsPath)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
onResourceClick?.({ kind: kindToPlural(kind), namespace, name, group })
|
|
191
|
+
}, [navigate, onResourceClick])
|
|
178
192
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
179
193
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
180
194
|
const [zoom, setZoom] = useState(1)
|
|
@@ -682,7 +696,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
682
696
|
)}
|
|
683
697
|
<div
|
|
684
698
|
className="flex-1 min-w-0 cursor-pointer hover:bg-theme-surface/30 rounded px-1 -mx-1 group"
|
|
685
|
-
onClick={() =>
|
|
699
|
+
onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
|
|
686
700
|
>
|
|
687
701
|
<div className="flex items-center gap-1">
|
|
688
702
|
<span className={clsx(
|
|
@@ -760,7 +774,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
760
774
|
<div className="flex">
|
|
761
775
|
<div
|
|
762
776
|
className="w-[19.25rem] shrink-0 border-r border-theme-border/50 pl-4 pr-3 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-theme-elevated/30 group"
|
|
763
|
-
onClick={() =>
|
|
777
|
+
onClick={() => handleLaneOpen(lane.kind, lane.namespace, lane.name, lane.group)}
|
|
764
778
|
>
|
|
765
779
|
<div className="flex-1 min-w-0">
|
|
766
780
|
<div className="flex items-center gap-1">
|
|
@@ -812,7 +826,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
812
826
|
{/* Child lane label - indented */}
|
|
813
827
|
<div
|
|
814
828
|
className="w-[19.25rem] shrink-0 border-r border-theme-border/50 pl-4 pr-3 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-theme-elevated/30 group"
|
|
815
|
-
onClick={() =>
|
|
829
|
+
onClick={() => handleLaneOpen(child.kind, child.namespace, child.name, child.group)}
|
|
816
830
|
>
|
|
817
831
|
<div className="flex-1 min-w-0">
|
|
818
832
|
<div className="flex items-center gap-1">
|
|
@@ -1234,7 +1248,7 @@ function EventDetailPanel({ event, onClose, onResourceClick }: EventDetailPanelP
|
|
|
1234
1248
|
{displayKind(event.kind)}
|
|
1235
1249
|
</span>
|
|
1236
1250
|
<button
|
|
1237
|
-
onClick={() => onResourceClick?.({ kind: kindToPlural(event.kind), namespace: event.namespace, name: event.name })}
|
|
1251
|
+
onClick={() => onResourceClick?.({ kind: kindToPlural(event.kind), namespace: event.namespace, name: event.name, group: apiVersionToGroup(event.apiVersion) })}
|
|
1238
1252
|
className="text-theme-text-primary font-medium hover:text-accent-text"
|
|
1239
1253
|
>
|
|
1240
1254
|
{event.name}
|
|
@@ -2,11 +2,12 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
|
|
2
2
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
3
3
|
import { Search, X, ChevronRight } from 'lucide-react'
|
|
4
4
|
import { Home, Network, List, Clock, Package, Activity, Sun, Stethoscope, DollarSign, ShieldCheck } from 'lucide-react'
|
|
5
|
+
import { GitBranch } from 'lucide-react'
|
|
5
6
|
import { clsx } from 'clsx'
|
|
6
7
|
import { useNamespaces, useContexts } from '../../api/client'
|
|
7
8
|
import { CORE_RESOURCES, useAPIResources } from '../../api/apiResources'
|
|
8
9
|
import { getResourceIcon } from '../../utils/resource-icons'
|
|
9
|
-
type MainView = 'home' | 'topology' | 'resources' | 'timeline' | 'helm' | 'traffic' | 'cost' | 'audit'
|
|
10
|
+
type MainView = 'home' | 'topology' | 'resources' | 'timeline' | 'helm' | 'traffic' | 'cost' | 'audit' | 'gitops'
|
|
10
11
|
|
|
11
12
|
interface CommandPaletteProps {
|
|
12
13
|
onClose: () => void
|
|
@@ -154,9 +155,10 @@ export function CommandPalette({
|
|
|
154
155
|
{ view: 'resources', label: 'Resources', icon: List, shortcut: '3' },
|
|
155
156
|
{ view: 'timeline', label: 'Timeline', icon: Clock, shortcut: '4' },
|
|
156
157
|
{ view: 'helm', label: 'Helm', icon: Package, shortcut: '5' },
|
|
157
|
-
{ view: '
|
|
158
|
-
{ view: '
|
|
159
|
-
{ view: '
|
|
158
|
+
{ view: 'gitops', label: 'GitOps', icon: GitBranch, shortcut: '6' },
|
|
159
|
+
{ view: 'traffic', label: 'Traffic', icon: Activity, shortcut: '7' },
|
|
160
|
+
{ view: 'cost', label: 'Cost', icon: DollarSign, shortcut: '8' },
|
|
161
|
+
{ view: 'audit', label: 'Audit', icon: ShieldCheck, shortcut: '9' },
|
|
160
162
|
]
|
|
161
163
|
for (const v of viewEntries) {
|
|
162
164
|
result.push({
|
|
@@ -19,7 +19,7 @@ const CONTEXT_CATEGORIES: ShortcutCategory[] = ['Drawer', 'Dock']
|
|
|
19
19
|
|
|
20
20
|
// Preferred ordering within the view section
|
|
21
21
|
const VIEW_CATEGORY_ORDER: ShortcutCategory[] = [
|
|
22
|
-
'Search', 'Table', 'Resource Actions', 'Topology', 'Timeline', 'Helm',
|
|
22
|
+
'Search', 'Table', 'Resource Actions', 'Topology', 'Timeline', 'Helm', 'GitOps',
|
|
23
23
|
]
|
|
24
24
|
|
|
25
25
|
const VIEW_LABELS: Record<string, string> = {
|
|
@@ -28,6 +28,7 @@ const VIEW_LABELS: Record<string, string> = {
|
|
|
28
28
|
resources: 'Resources',
|
|
29
29
|
timeline: 'Timeline',
|
|
30
30
|
helm: 'Helm',
|
|
31
|
+
gitops: 'GitOps',
|
|
31
32
|
traffic: 'Traffic',
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useApplyDesktopUpdate,
|
|
9
9
|
} from '../../api/client'
|
|
10
10
|
import type { DesktopUpdateState } from '../../api/client'
|
|
11
|
+
import { WithTooltip } from './Tooltip'
|
|
11
12
|
|
|
12
13
|
const DISMISSED_KEY = 'radar-update-dismissed'
|
|
13
14
|
|
|
@@ -117,7 +118,7 @@ export function UpdateNotification() {
|
|
|
117
118
|
<UpdateIcon state={effectiveState} />
|
|
118
119
|
</div>
|
|
119
120
|
<div className="flex-1 min-w-0">
|
|
120
|
-
<h4 className="text-sm font-medium text-theme-text-primary">
|
|
121
|
+
<h4 className="text-sm font-medium text-theme-text-primary pr-6">
|
|
121
122
|
<UpdateTitle state={effectiveState} />
|
|
122
123
|
</h4>
|
|
123
124
|
<p className="text-xs text-theme-text-secondary mt-1">
|
|
@@ -140,15 +141,30 @@ export function UpdateNotification() {
|
|
|
140
141
|
|
|
141
142
|
{/* CLI: show update command with copy button for package managers */}
|
|
142
143
|
{!isDesktop && versionInfo.updateCommand ? (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
144
|
+
<>
|
|
145
|
+
<WithTooltip tip={versionInfo.updateCommand} delay={100}>
|
|
146
|
+
<button
|
|
147
|
+
onClick={handleCopyCommand}
|
|
148
|
+
className="flex items-center gap-2 mt-2 px-2 py-1.5 bg-theme-elevated rounded font-mono text-theme-text-primary hover:bg-theme-surface-hover transition-colors w-full"
|
|
149
|
+
>
|
|
150
|
+
<code className="flex-1 text-left truncate text-[11px]">{versionInfo.updateCommand}</code>
|
|
151
|
+
<CopyIcon copied={copied} failed={copyFailed} />
|
|
152
|
+
</button>
|
|
153
|
+
</WithTooltip>
|
|
154
|
+
{/* Direct installs may have placed the binary somewhere the install
|
|
155
|
+
script won't touch — surface a download link as a fallback. */}
|
|
156
|
+
{versionInfo.installMethod === 'direct' && versionInfo.releaseUrl && (
|
|
157
|
+
<a
|
|
158
|
+
href={versionInfo.releaseUrl}
|
|
159
|
+
target="_blank"
|
|
160
|
+
rel="noopener noreferrer"
|
|
161
|
+
className="inline-flex items-center gap-1 mt-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-secondary hover:underline"
|
|
162
|
+
>
|
|
163
|
+
or download from GitHub →
|
|
164
|
+
</a>
|
|
165
|
+
)}
|
|
166
|
+
</>
|
|
150
167
|
) : (
|
|
151
|
-
/* Direct download - show release link */
|
|
152
168
|
!isDesktop && versionInfo.releaseUrl && (
|
|
153
169
|
<a
|
|
154
170
|
href={versionInfo.releaseUrl}
|
|
@@ -161,17 +177,18 @@ export function UpdateNotification() {
|
|
|
161
177
|
)
|
|
162
178
|
)}
|
|
163
179
|
</div>
|
|
164
|
-
{/* Don't show dismiss during active update */}
|
|
165
|
-
{effectiveState !== 'downloading' && effectiveState !== 'applying' && (
|
|
166
|
-
<button
|
|
167
|
-
onClick={handleDismiss}
|
|
168
|
-
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
|
|
169
|
-
aria-label="Dismiss"
|
|
170
|
-
>
|
|
171
|
-
<X className="w-4 h-4" />
|
|
172
|
-
</button>
|
|
173
|
-
)}
|
|
174
180
|
</div>
|
|
181
|
+
{/* Dismiss is absolute so it doesn't compress the chip's width.
|
|
182
|
+
fixed on the parent already establishes the positioning context. */}
|
|
183
|
+
{effectiveState !== 'downloading' && effectiveState !== 'applying' && (
|
|
184
|
+
<button
|
|
185
|
+
onClick={handleDismiss}
|
|
186
|
+
className="absolute top-2 right-2 p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
187
|
+
aria-label="Dismiss"
|
|
188
|
+
>
|
|
189
|
+
<X className="w-4 h-4" />
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
175
192
|
</div>
|
|
176
193
|
)
|
|
177
194
|
}
|