@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.
@@ -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: Math.max(16, window.innerWidth - rect.right),
145
- caretRight: rect.width / 2 - 6,
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(`http://localhost:${session.localPort}`)
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(`http://localhost:${session.localPort}`)
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-64 overflow-y-auto">
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-1 min-w-0">
645
- <div className="flex items-center gap-2">
646
- <span
647
- className={clsx(
648
- 'w-2 h-2 rounded-full shrink-0',
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
- </div>
659
- <div className="mt-1 text-xs text-theme-text-disabled">
660
- {session.namespace} · Port {session.podPort}
661
- </div>
662
- {session.status === 'error' && session.error && (
663
- <div className="mt-1.5 text-xs text-red-400 bg-red-500/10 px-2 py-1 rounded">
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
- <div className="mt-1.5 flex items-center gap-2">
669
- {editingPortId === session.id ? (
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
- <Tooltip
737
- content={session.listenAddress === '0.0.0.0' ? 'Switch to localhost only' : 'Allow access from other machines'}
738
- delay={300} position="bottom" disabled={!isPanelOpen}
739
- >
740
- <button
741
- onClick={() => toggleListenAddress(session)}
742
- disabled={togglingId === session.id || changingPortId === session.id}
743
- className={clsx(
744
- 'flex items-center gap-1 px-1.5 py-0.5 text-xs rounded transition-colors',
745
- session.listenAddress === '0.0.0.0'
746
- ? `${SEVERITY_BADGE.warning} hover:bg-amber-500/30`
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
- </div>
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 { NavigateToResource } from '../../utils/navigation'
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-blue-500 focus:border-transparent"
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-blue-600 dark:text-blue-300 hover:text-blue-700 dark:hover:text-blue-200 hover:bg-theme-elevated rounded"
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-blue-500 focus:ring-blue-500 focus:ring-offset-0"
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-blue-500/15 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : 'bg-theme-elevated text-theme-text-secondary'
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-blue-600 dark:group-hover:text-blue-300 group-hover:underline cursor-pointer">
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-blue-500/40 ml-3 bg-theme-surface/30"
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-blue-500/15 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300">
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-blue-600 dark:group-hover:text-blue-300 group-hover:underline cursor-pointer">
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-blue-600 dark:group-hover:text-blue-300 group-hover:underline cursor-pointer">
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
- const opacity = dimmed ? '/50' : ''
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 `bg-amber-500${opacity}`
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 `bg-green-500${opacity}`
1104
+ return dimmed ? 'bg-green-500/50' : 'bg-green-500'
1106
1105
  case 'delete':
1107
- return `bg-red-500${opacity}`
1106
+ return dimmed ? 'bg-red-500/50' : 'bg-red-500'
1108
1107
  case 'update':
1109
- return `bg-blue-500${opacity}`
1108
+ return dimmed ? 'bg-blue-500/50' : 'bg-blue-500'
1110
1109
  }
1111
1110
  }
1112
- return `bg-theme-text-tertiary${opacity}`
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-blue-600 dark:hover:text-blue-300"
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
  }