@skyhook-io/radar-app 0.2.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.tsx +143 -36
- package/src/api/client.ts +121 -4
- package/src/components/ContextSwitcher.tsx +49 -16
- package/src/components/NamespaceSwitcher.tsx +298 -0
- package/src/components/audit/AuditSettingsDialog.tsx +49 -10
- package/src/components/helm/ChartBrowser.tsx +11 -11
- package/src/components/helm/HelmReleaseDrawer.tsx +35 -19
- package/src/components/helm/HelmView.tsx +33 -7
- package/src/components/helm/InstallWizard.tsx +79 -22
- package/src/components/home/HomeView.tsx +13 -1
- package/src/components/portforward/PortForwardButton.tsx +37 -16
- package/src/components/portforward/PortForwardManager.tsx +152 -111
- package/src/components/resources/ResourcesView.tsx +2 -2
- package/src/components/timeline/TimelineSwimlanes.tsx +17 -18
- package/src/components/ui/DiagnosticsOverlay.tsx +93 -2
- package/src/components/ui/UpdateNotification.tsx +7 -7
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -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() {
|
|
@@ -139,10 +172,25 @@ export function PortForwardProvider({ children }: { children: ReactNode }) {
|
|
|
139
172
|
const measureAnchor = useCallback(() => {
|
|
140
173
|
if (!indicatorRef.current) return
|
|
141
174
|
const rect = indicatorRef.current.getBoundingClientRect()
|
|
175
|
+
// Align panel right edge with indicator right edge, but clamp so the
|
|
176
|
+
// panel's left edge never runs off the viewport's left edge — happens on
|
|
177
|
+
// narrow windows / split-screens where the indicator is closer to the
|
|
178
|
+
// left edge than the panel is wide. PANEL_WIDTH must match the panel's
|
|
179
|
+
// Tailwind w-80 (20rem = 320px).
|
|
180
|
+
const PANEL_WIDTH = 320
|
|
181
|
+
const MARGIN = 8
|
|
182
|
+
const desiredRight = Math.max(MARGIN, window.innerWidth - rect.right)
|
|
183
|
+
const maxRight = Math.max(MARGIN, window.innerWidth - PANEL_WIDTH - MARGIN)
|
|
184
|
+
const right = Math.min(desiredRight, maxRight)
|
|
185
|
+
// Keep the caret pointing at the indicator's horizontal center even after
|
|
186
|
+
// the panel has been clamped away from the indicator.
|
|
187
|
+
const panelRightX = window.innerWidth - right
|
|
188
|
+
const indicatorCenterX = rect.right - rect.width / 2
|
|
189
|
+
const caretRight = panelRightX - indicatorCenterX - 6
|
|
142
190
|
setAnchor({
|
|
143
191
|
top: rect.bottom + 10,
|
|
144
|
-
right
|
|
145
|
-
caretRight
|
|
192
|
+
right,
|
|
193
|
+
caretRight,
|
|
146
194
|
})
|
|
147
195
|
}, [])
|
|
148
196
|
|
|
@@ -415,14 +463,7 @@ export function PortForwardPanel() {
|
|
|
415
463
|
const res = await fetch(apiUrl('/portforwards'), {
|
|
416
464
|
method: 'POST',
|
|
417
465
|
headers: { 'Content-Type': 'application/json' },
|
|
418
|
-
body: JSON.stringify({
|
|
419
|
-
namespace: session.namespace,
|
|
420
|
-
podName: session.podName || undefined,
|
|
421
|
-
serviceName: session.serviceName || undefined,
|
|
422
|
-
podPort: session.podPort,
|
|
423
|
-
localPort: session.localPort,
|
|
424
|
-
listenAddress: newAddress,
|
|
425
|
-
}),
|
|
466
|
+
body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: newAddress })),
|
|
426
467
|
})
|
|
427
468
|
if (!res.ok) {
|
|
428
469
|
const error = await res.json().catch(() => ({}))
|
|
@@ -469,14 +510,7 @@ export function PortForwardPanel() {
|
|
|
469
510
|
const res = await fetch(apiUrl('/portforwards'), {
|
|
470
511
|
method: 'POST',
|
|
471
512
|
headers: { 'Content-Type': 'application/json' },
|
|
472
|
-
body: JSON.stringify({
|
|
473
|
-
namespace: session.namespace,
|
|
474
|
-
podName: session.podName || undefined,
|
|
475
|
-
serviceName: session.serviceName || undefined,
|
|
476
|
-
podPort: session.podPort,
|
|
477
|
-
localPort: newPort,
|
|
478
|
-
listenAddress: session.listenAddress,
|
|
479
|
-
}),
|
|
513
|
+
body: JSON.stringify(buildRecreateBody(session, { localPort: newPort, listenAddress: session.listenAddress })),
|
|
480
514
|
})
|
|
481
515
|
if (!res.ok) {
|
|
482
516
|
const error = await res.json().catch(() => ({}))
|
|
@@ -506,7 +540,7 @@ export function PortForwardPanel() {
|
|
|
506
540
|
async (session: PortForwardSession) => {
|
|
507
541
|
commitInteraction()
|
|
508
542
|
try {
|
|
509
|
-
await navigator.clipboard.writeText(
|
|
543
|
+
await navigator.clipboard.writeText(sessionUrl(session))
|
|
510
544
|
} catch (err) {
|
|
511
545
|
// Clipboard API can reject in non-secure contexts, denied permissions, or
|
|
512
546
|
// when the document isn't focused. Surface the failure — the checkmark
|
|
@@ -529,7 +563,7 @@ export function PortForwardPanel() {
|
|
|
529
563
|
const handleOpenUrl = useCallback(
|
|
530
564
|
(session: PortForwardSession) => {
|
|
531
565
|
commitInteraction()
|
|
532
|
-
openExternal(
|
|
566
|
+
openExternal(sessionUrl(session))
|
|
533
567
|
},
|
|
534
568
|
[commitInteraction]
|
|
535
569
|
)
|
|
@@ -611,7 +645,7 @@ export function PortForwardPanel() {
|
|
|
611
645
|
</div>
|
|
612
646
|
|
|
613
647
|
{/* Sessions list */}
|
|
614
|
-
<div className="max-h-
|
|
648
|
+
<div className="max-h-[28rem] overflow-y-auto">
|
|
615
649
|
{isQueryError ? (
|
|
616
650
|
<div className="p-3 text-xs bg-red-500/10 border-b border-theme-border">
|
|
617
651
|
<div className={clsx('badge-sm mb-1 inline-block', SEVERITY_BADGE.error)}>
|
|
@@ -636,37 +670,84 @@ export function PortForwardPanel() {
|
|
|
636
670
|
<div
|
|
637
671
|
key={session.id}
|
|
638
672
|
className={clsx(
|
|
639
|
-
'p-3',
|
|
673
|
+
'p-3 space-y-1',
|
|
640
674
|
session.status === 'error' ? 'bg-red-500/10' : 'hover:bg-theme-elevated'
|
|
641
675
|
)}
|
|
642
676
|
>
|
|
677
|
+
{/* Row 1: status dot + name | stop button */}
|
|
643
678
|
<div className="flex items-start justify-between gap-2">
|
|
644
|
-
<div className="flex-
|
|
645
|
-
<
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
session.status === 'running' ? 'bg-green-500' : 'bg-red-500'
|
|
650
|
-
)}
|
|
651
|
-
/>
|
|
652
|
-
<span className="text-sm text-theme-text-primary font-medium truncate">
|
|
653
|
-
{session.serviceName || session.podName}
|
|
654
|
-
</span>
|
|
655
|
-
{session.status === 'error' && (
|
|
656
|
-
<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'
|
|
657
684
|
)}
|
|
658
|
-
|
|
659
|
-
<
|
|
660
|
-
{session.
|
|
661
|
-
</
|
|
662
|
-
{session.status === 'error' &&
|
|
663
|
-
<
|
|
664
|
-
{session.error}
|
|
665
|
-
</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>
|
|
666
691
|
)}
|
|
692
|
+
</div>
|
|
693
|
+
<div className="flex items-center gap-0.5 shrink-0">
|
|
667
694
|
{session.status === 'running' && (
|
|
668
|
-
<
|
|
669
|
-
{
|
|
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 ? (
|
|
670
751
|
<div className="flex items-center text-xs bg-theme-base rounded text-accent-text font-mono">
|
|
671
752
|
<span className="pl-2 py-1 text-theme-text-disabled select-none">
|
|
672
753
|
{session.listenAddress === '0.0.0.0' ? '0.0.0.0' : 'localhost'}:
|
|
@@ -709,6 +790,7 @@ export function PortForwardPanel() {
|
|
|
709
790
|
/>
|
|
710
791
|
</div>
|
|
711
792
|
) : (
|
|
793
|
+
<>
|
|
712
794
|
<Tooltip content="Click to change local port" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
713
795
|
<code
|
|
714
796
|
className={clsx(
|
|
@@ -732,74 +814,33 @@ export function PortForwardPanel() {
|
|
|
732
814
|
<PenLine className="w-3 h-3 text-theme-text-disabled opacity-0 group-hover/port:opacity-100 transition-opacity" />
|
|
733
815
|
</code>
|
|
734
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" />
|
|
735
830
|
)}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
: 'bg-theme-elevated text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary'
|
|
748
|
-
)}
|
|
749
|
-
>
|
|
750
|
-
{togglingId === session.id ? (
|
|
751
|
-
<Loader2 className="w-3 h-3 animate-spin" />
|
|
752
|
-
) : session.listenAddress === '0.0.0.0' ? (
|
|
753
|
-
<Globe className="w-3 h-3" />
|
|
754
|
-
) : (
|
|
755
|
-
<Monitor className="w-3 h-3" />
|
|
756
|
-
)}
|
|
757
|
-
{session.listenAddress === '0.0.0.0' ? 'network' : 'local'}
|
|
758
|
-
</button>
|
|
759
|
-
</Tooltip>
|
|
760
|
-
</div>
|
|
761
|
-
)}
|
|
762
|
-
</div>
|
|
763
|
-
|
|
764
|
-
<div className="flex items-center gap-1 shrink-0">
|
|
765
|
-
{session.status === 'running' && (
|
|
766
|
-
<>
|
|
767
|
-
<Tooltip content={copiedId === session.id ? 'Copied!' : 'Copy URL'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
768
|
-
<button
|
|
769
|
-
onClick={() => handleCopyUrl(session)}
|
|
770
|
-
className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
771
|
-
>
|
|
772
|
-
{copiedId === session.id ? (
|
|
773
|
-
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
774
|
-
) : (
|
|
775
|
-
<Copy className="w-3.5 h-3.5" />
|
|
776
|
-
)}
|
|
777
|
-
</button>
|
|
778
|
-
</Tooltip>
|
|
779
|
-
<Tooltip content="Open in browser" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
780
|
-
<button
|
|
781
|
-
onClick={() => handleOpenUrl(session)}
|
|
782
|
-
className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
783
|
-
>
|
|
784
|
-
<ExternalLink className="w-3.5 h-3.5" />
|
|
785
|
-
</button>
|
|
786
|
-
</Tooltip>
|
|
787
|
-
</>
|
|
788
|
-
)}
|
|
789
|
-
<Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
790
|
-
<button
|
|
791
|
-
onClick={() => {
|
|
792
|
-
commitInteraction()
|
|
793
|
-
stopPortForward(session.id)
|
|
794
|
-
}}
|
|
795
|
-
disabled={stoppingIds.has(session.id)}
|
|
796
|
-
className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
|
|
797
|
-
>
|
|
798
|
-
<Trash2 className="w-3.5 h-3.5" />
|
|
799
|
-
</button>
|
|
800
|
-
</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>
|
|
801
842
|
</div>
|
|
802
|
-
|
|
843
|
+
)}
|
|
803
844
|
</div>
|
|
804
845
|
))}
|
|
805
846
|
</div>
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from '@skyhook-io/k8s-ui'
|
|
14
14
|
import type { ResourceQueryResult } from '@skyhook-io/k8s-ui'
|
|
15
15
|
import type { SelectedResource } from '../../types'
|
|
16
|
-
import type
|
|
16
|
+
import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
|
|
17
17
|
import { CreateResourceDialog } from '../shared/CreateResourceDialog'
|
|
18
18
|
import { getSkeletonYaml } from '../../utils/skeleton-yaml'
|
|
19
19
|
|
|
@@ -204,7 +204,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
204
204
|
initialYaml={createDialogYaml}
|
|
205
205
|
title={createDialogTitle}
|
|
206
206
|
onCreated={(result) => {
|
|
207
|
-
onResourceClick?.({ kind: result.kind, namespace: result.namespace, name: result.name, group: '' })
|
|
207
|
+
onResourceClick?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
|
|
208
208
|
}}
|
|
209
209
|
/>
|
|
210
210
|
</>
|
|
@@ -457,7 +457,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
457
457
|
value={searchTerm}
|
|
458
458
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
459
459
|
placeholder="Search... (press /)"
|
|
460
|
-
className="w-80 pl-9 pr-8 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-
|
|
460
|
+
className="w-80 pl-9 pr-8 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
|
461
461
|
/>
|
|
462
462
|
{searchTerm && (
|
|
463
463
|
<button
|
|
@@ -490,7 +490,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
490
490
|
{panOffset > 0 && (
|
|
491
491
|
<button
|
|
492
492
|
onClick={() => setPanOffset(0)}
|
|
493
|
-
className="px-2 py-1 text-xs text-
|
|
493
|
+
className="px-2 py-1 text-xs text-accent-text hover:underline hover:bg-theme-elevated rounded"
|
|
494
494
|
title="Jump to current time"
|
|
495
495
|
>
|
|
496
496
|
→ Now
|
|
@@ -519,7 +519,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
519
519
|
type="checkbox"
|
|
520
520
|
checked={groupByApp}
|
|
521
521
|
onChange={(e) => setGroupByApp(e.target.checked)}
|
|
522
|
-
className="w-3.5 h-3.5 rounded border-theme-border-light bg-theme-elevated text-
|
|
522
|
+
className="w-3.5 h-3.5 rounded border-theme-border-light bg-theme-elevated text-accent focus:ring-accent focus:ring-offset-0"
|
|
523
523
|
/>
|
|
524
524
|
<span className="border-b border-dotted border-theme-text-tertiary">Group by app</span>
|
|
525
525
|
</label>
|
|
@@ -687,7 +687,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
687
687
|
<div className="flex items-center gap-1">
|
|
688
688
|
<span className={clsx(
|
|
689
689
|
'text-xs px-1 py-0.5 rounded',
|
|
690
|
-
lane.isWorkload ? 'bg-
|
|
690
|
+
lane.isWorkload ? 'bg-accent-muted text-accent-text' : 'bg-theme-elevated text-theme-text-secondary'
|
|
691
691
|
)}>
|
|
692
692
|
{displayKind(lane.kind)}
|
|
693
693
|
</span>
|
|
@@ -711,7 +711,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
711
711
|
)
|
|
712
712
|
})()}
|
|
713
713
|
</div>
|
|
714
|
-
<div className="text-sm text-theme-text-primary break-words group-hover:text-
|
|
714
|
+
<div className="text-sm text-theme-text-primary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
|
|
715
715
|
{lane.name}
|
|
716
716
|
</div>
|
|
717
717
|
<div className="text-xs text-theme-text-tertiary">{lane.namespace}</div>
|
|
@@ -751,7 +751,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
751
751
|
{/* Child lanes (when expanded) - includes parent as first row */}
|
|
752
752
|
{isExpanded && hasChildren && (
|
|
753
753
|
<div
|
|
754
|
-
className="border-l-2 border-
|
|
754
|
+
className="border-l-2 border-accent/40 ml-3 bg-theme-surface/30"
|
|
755
755
|
style={{ animation: 'swimlane-expand 250ms ease-out both' }}
|
|
756
756
|
>
|
|
757
757
|
{/* Parent's own events as first row (only if it has events) */}
|
|
@@ -764,11 +764,11 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
764
764
|
>
|
|
765
765
|
<div className="flex-1 min-w-0">
|
|
766
766
|
<div className="flex items-center gap-1">
|
|
767
|
-
<span className="text-xs px-1 py-0.5 rounded bg-
|
|
767
|
+
<span className="text-xs px-1 py-0.5 rounded bg-accent-muted text-accent-text">
|
|
768
768
|
{displayKind(lane.kind)}
|
|
769
769
|
</span>
|
|
770
770
|
</div>
|
|
771
|
-
<div className="text-sm text-theme-text-secondary break-words group-hover:text-
|
|
771
|
+
<div className="text-sm text-theme-text-secondary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
|
|
772
772
|
{lane.name}
|
|
773
773
|
</div>
|
|
774
774
|
</div>
|
|
@@ -820,7 +820,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
820
820
|
{displayKind(child.kind)}
|
|
821
821
|
</span>
|
|
822
822
|
</div>
|
|
823
|
-
<div className="text-sm text-theme-text-secondary break-words group-hover:text-
|
|
823
|
+
<div className="text-sm text-theme-text-secondary break-words group-hover:text-accent-text group-hover:underline cursor-pointer">
|
|
824
824
|
{child.name}
|
|
825
825
|
</div>
|
|
826
826
|
</div>
|
|
@@ -1093,23 +1093,22 @@ function EventMarker({ event, x, selected, onClick, dimmed, small }: EventMarker
|
|
|
1093
1093
|
return 'bg-red-500'
|
|
1094
1094
|
}
|
|
1095
1095
|
|
|
1096
|
-
// Solid fill for real-time events
|
|
1097
|
-
|
|
1098
|
-
// Problematic events (warnings, BackOff, etc.) are always amber/orange
|
|
1096
|
+
// Solid fill for real-time events.
|
|
1097
|
+
// Problematic events (warnings, BackOff, etc.) are always amber/orange.
|
|
1099
1098
|
if (isProblematic) {
|
|
1100
|
-
return
|
|
1099
|
+
return dimmed ? 'bg-amber-500/50' : 'bg-amber-500'
|
|
1101
1100
|
}
|
|
1102
1101
|
if (isChange) {
|
|
1103
1102
|
switch (event.eventType) {
|
|
1104
1103
|
case 'add':
|
|
1105
|
-
return
|
|
1104
|
+
return dimmed ? 'bg-green-500/50' : 'bg-green-500'
|
|
1106
1105
|
case 'delete':
|
|
1107
|
-
return
|
|
1106
|
+
return dimmed ? 'bg-red-500/50' : 'bg-red-500'
|
|
1108
1107
|
case 'update':
|
|
1109
|
-
return
|
|
1108
|
+
return dimmed ? 'bg-blue-500/50' : 'bg-blue-500'
|
|
1110
1109
|
}
|
|
1111
1110
|
}
|
|
1112
|
-
return
|
|
1111
|
+
return dimmed ? 'bg-theme-text-tertiary/50' : 'bg-theme-text-tertiary'
|
|
1113
1112
|
}
|
|
1114
1113
|
|
|
1115
1114
|
const markerClasses = getMarkerStyle()
|
|
@@ -1236,7 +1235,7 @@ function EventDetailPanel({ event, onClose, onResourceClick }: EventDetailPanelP
|
|
|
1236
1235
|
</span>
|
|
1237
1236
|
<button
|
|
1238
1237
|
onClick={() => onResourceClick?.({ kind: kindToPlural(event.kind), namespace: event.namespace, name: event.name })}
|
|
1239
|
-
className="text-theme-text-primary font-medium hover:text-
|
|
1238
|
+
className="text-theme-text-primary font-medium hover:text-accent-text"
|
|
1240
1239
|
>
|
|
1241
1240
|
{event.name}
|
|
1242
1241
|
</button>
|
|
@@ -4,7 +4,7 @@ import { clsx } from 'clsx'
|
|
|
4
4
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
5
5
|
import { openExternal } from '../../utils/navigation'
|
|
6
6
|
import { useDiagnostics } from '../../api/client'
|
|
7
|
-
import type { DiagnosticsSnapshot, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry } from '../../api/client'
|
|
7
|
+
import type { DiagnosticsSnapshot, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase } from '../../api/client'
|
|
8
8
|
|
|
9
9
|
interface DiagnosticsOverlayProps {
|
|
10
10
|
onClose: () => void
|
|
@@ -315,10 +315,41 @@ function EventPipelineSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
|
315
315
|
function InformersSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
316
316
|
if (!data.informers) return null
|
|
317
317
|
const inf = data.informers
|
|
318
|
+
const sync = inf.syncStatus
|
|
319
|
+
const phaseWarn = sync ? sync.phase !== 'complete' : false
|
|
320
|
+
const criticalWarn = sync ? sync.criticalSynced < sync.criticalTotal : false
|
|
321
|
+
const promoted = sync?.promotedKinds ?? []
|
|
322
|
+
const pendingCritical = sync?.pendingCritical ?? []
|
|
323
|
+
const pendingDeferred = sync?.pendingDeferred ?? []
|
|
324
|
+
const sectionWarn = phaseWarn || criticalWarn || promoted.length > 0
|
|
318
325
|
return (
|
|
319
|
-
<Section title="Informers">
|
|
326
|
+
<Section title="Informers" warn={sectionWarn}>
|
|
320
327
|
<Row label="Typed" value={inf.typedCount} />
|
|
321
328
|
<Row label="Dynamic (CRDs)" value={inf.dynamicCount} />
|
|
329
|
+
{sync && (
|
|
330
|
+
<>
|
|
331
|
+
<Row
|
|
332
|
+
label="Sync Phase"
|
|
333
|
+
value={`${formatSyncPhase(sync.phase)} (${formatElapsed(sync.elapsedSec)})`}
|
|
334
|
+
warn={phaseWarn}
|
|
335
|
+
/>
|
|
336
|
+
<Row
|
|
337
|
+
label="Critical Synced"
|
|
338
|
+
value={`${sync.criticalSynced} / ${sync.criticalTotal}`}
|
|
339
|
+
warn={criticalWarn}
|
|
340
|
+
/>
|
|
341
|
+
<Row
|
|
342
|
+
label="Deferred Synced"
|
|
343
|
+
value={`${sync.deferredSynced} / ${sync.deferredTotal}`}
|
|
344
|
+
/>
|
|
345
|
+
{promoted.length > 0 && (
|
|
346
|
+
<Row label="Promoted to Deferred" value={promoted.join(', ')} warn />
|
|
347
|
+
)}
|
|
348
|
+
{(pendingCritical.length > 0 || pendingDeferred.length > 0) && (
|
|
349
|
+
<PendingInformers sync={sync} />
|
|
350
|
+
)}
|
|
351
|
+
</>
|
|
352
|
+
)}
|
|
322
353
|
{inf.watchedCRDs && inf.watchedCRDs.length > 0 && (
|
|
323
354
|
<Row label="Watched CRDs" value={inf.watchedCRDs.join(', ')} />
|
|
324
355
|
)}
|
|
@@ -326,6 +357,53 @@ function InformersSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
|
326
357
|
)
|
|
327
358
|
}
|
|
328
359
|
|
|
360
|
+
function PendingInformers({ sync }: { sync: DiagCacheSyncStatus }) {
|
|
361
|
+
const pending = getPendingInformers(sync)
|
|
362
|
+
if (pending.length === 0) return null
|
|
363
|
+
return (
|
|
364
|
+
<div className="mt-1.5 pt-1.5 border-t border-theme-border-light">
|
|
365
|
+
<span className="text-[10px] text-theme-text-tertiary uppercase">Pending Informers ({pending.length})</span>
|
|
366
|
+
{pending.map((i: DiagInformerSyncStatus) => (
|
|
367
|
+
<Row
|
|
368
|
+
key={i.kind}
|
|
369
|
+
label={`${i.kind} (${i.deferred ? 'deferred' : 'critical'})`}
|
|
370
|
+
value={`${i.items.toLocaleString()} items so far`}
|
|
371
|
+
warn={!i.deferred}
|
|
372
|
+
/>
|
|
373
|
+
))}
|
|
374
|
+
</div>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function getPendingInformers(sync: DiagCacheSyncStatus): DiagInformerSyncStatus[] {
|
|
379
|
+
const pendingNames = new Set([
|
|
380
|
+
...(sync.pendingCritical ?? []),
|
|
381
|
+
...(sync.pendingDeferred ?? []),
|
|
382
|
+
])
|
|
383
|
+
return sync.informers
|
|
384
|
+
.filter((i) => pendingNames.has(i.kind))
|
|
385
|
+
.sort((a, b) => Number(a.deferred) - Number(b.deferred) || a.kind.localeCompare(b.kind))
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function formatSyncPhase(phase: DiagSyncPhase): string {
|
|
389
|
+
switch (phase) {
|
|
390
|
+
case 'not_started': return 'not started'
|
|
391
|
+
case 'syncing_critical': return 'syncing critical'
|
|
392
|
+
case 'syncing_deferred': return 'syncing deferred'
|
|
393
|
+
case 'complete': return 'complete'
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function formatElapsed(sec: number): string {
|
|
398
|
+
const s = Math.max(0, sec)
|
|
399
|
+
if (s < 1) return `${Math.round(s * 1000)}ms`
|
|
400
|
+
if (s < 60) return `${s.toFixed(1)}s`
|
|
401
|
+
const total = Math.round(s)
|
|
402
|
+
const m = Math.floor(total / 60)
|
|
403
|
+
const rem = total - m * 60
|
|
404
|
+
return `${m}m ${rem}s`
|
|
405
|
+
}
|
|
406
|
+
|
|
329
407
|
function PrometheusSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
330
408
|
if (!data.prometheus) return null
|
|
331
409
|
const p = data.prometheus
|
|
@@ -512,6 +590,19 @@ function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): stri
|
|
|
512
590
|
const inf = data.informers
|
|
513
591
|
lines.push(`### Informers`)
|
|
514
592
|
lines.push(`- Typed: ${inf.typedCount} | Dynamic: ${inf.dynamicCount}`)
|
|
593
|
+
if (inf.syncStatus) {
|
|
594
|
+
const sync = inf.syncStatus
|
|
595
|
+
lines.push(`- Sync Phase: \`${sync.phase}\` (${formatElapsed(sync.elapsedSec)})`)
|
|
596
|
+
lines.push(`- Critical: ${sync.criticalSynced}/${sync.criticalTotal} synced | Deferred: ${sync.deferredSynced}/${sync.deferredTotal} synced`)
|
|
597
|
+
if (sync.promotedKinds && sync.promotedKinds.length > 0) {
|
|
598
|
+
lines.push(`- **Promoted to Deferred:** ${sync.promotedKinds.join(', ')}`)
|
|
599
|
+
}
|
|
600
|
+
const pending = getPendingInformers(sync)
|
|
601
|
+
if (pending.length > 0) {
|
|
602
|
+
const parts = pending.map((i) => `${i.kind}(${i.deferred ? 'deferred' : 'critical'},${i.items.toLocaleString()} items)`)
|
|
603
|
+
lines.push(`- **Pending:** ${parts.join(', ')}`)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
515
606
|
if (inf.watchedCRDs && inf.watchedCRDs.length > 0) {
|
|
516
607
|
lines.push(`- CRDs: ${inf.watchedCRDs.join(', ')}`)
|
|
517
608
|
}
|