@skyhook-io/radar-app 0.2.2 → 0.3.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.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -4,7 +4,8 @@ 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, DiagSampleWindow } from '../../api/client'
8
+ import { getK8sUIPerfSnapshot, type K8sUIPerfSnapshot } from '@skyhook-io/k8s-ui'
8
9
 
9
10
  interface DiagnosticsOverlayProps {
10
11
  onClose: () => void
@@ -31,9 +32,10 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
31
32
 
32
33
  const copyToClipboard = useCallback(async (type: 'json' | 'formatted') => {
33
34
  if (!data) return
35
+ const frontendPerf = getK8sUIPerfSnapshot()
34
36
  const text = type === 'json'
35
- ? JSON.stringify(data, null, 2)
36
- : formatForGitHub(data)
37
+ ? JSON.stringify({ ...data, frontendPerf }, null, 2)
38
+ : formatForGitHub(data, frontendPerf)
37
39
  try {
38
40
  await navigator.clipboard.writeText(text)
39
41
  setCopied(type)
@@ -46,7 +48,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
46
48
 
47
49
  const openBugReport = useCallback(() => {
48
50
  if (!data) return
49
- const body = formatForBugReport(data)
51
+ const body = formatForBugReport(data, getK8sUIPerfSnapshot())
50
52
  const url = `https://github.com/skyhook-io/radar/issues/new?labels=bug&body=${encodeURIComponent(body)}`
51
53
  if (url.length > 8000) {
52
54
  // URL too long for GitHub — copy diagnostics to clipboard and open blank issue
@@ -97,7 +99,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
97
99
  {/* Content */}
98
100
  <div className="overflow-y-auto flex-1 px-5 py-4 space-y-4">
99
101
  {isLoading && (
100
- <div className="text-sm text-theme-text-tertiary text-center py-8">Loading diagnostics...</div>
102
+ <div className="text-sm text-theme-text-tertiary text-center py-8">Loading diagnostics…</div>
101
103
  )}
102
104
  {error && (
103
105
  <div className="text-sm text-red-400 text-center py-8">Failed to load diagnostics: {(error as Error).message}</div>
@@ -116,6 +118,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
116
118
  <TrafficSection data={data} />
117
119
  <PermissionsSection data={data} />
118
120
  <APIDiscoverySection data={data} />
121
+ <PerfSection data={data} />
119
122
  <RuntimeSection data={data} />
120
123
  <ConfigSection data={data} />
121
124
  {data.errors && data.errors.length > 0 && (
@@ -315,10 +318,41 @@ function EventPipelineSection({ data }: { data: DiagnosticsSnapshot }) {
315
318
  function InformersSection({ data }: { data: DiagnosticsSnapshot }) {
316
319
  if (!data.informers) return null
317
320
  const inf = data.informers
321
+ const sync = inf.syncStatus
322
+ const phaseWarn = sync ? sync.phase !== 'complete' : false
323
+ const criticalWarn = sync ? sync.criticalSynced < sync.criticalTotal : false
324
+ const promoted = sync?.promotedKinds ?? []
325
+ const pendingCritical = sync?.pendingCritical ?? []
326
+ const pendingDeferred = sync?.pendingDeferred ?? []
327
+ const sectionWarn = phaseWarn || criticalWarn || promoted.length > 0
318
328
  return (
319
- <Section title="Informers">
329
+ <Section title="Informers" warn={sectionWarn}>
320
330
  <Row label="Typed" value={inf.typedCount} />
321
331
  <Row label="Dynamic (CRDs)" value={inf.dynamicCount} />
332
+ {sync && (
333
+ <>
334
+ <Row
335
+ label="Sync Phase"
336
+ value={`${formatSyncPhase(sync.phase)} (${formatElapsed(sync.elapsedSec)})`}
337
+ warn={phaseWarn}
338
+ />
339
+ <Row
340
+ label="Critical Synced"
341
+ value={`${sync.criticalSynced} / ${sync.criticalTotal}`}
342
+ warn={criticalWarn}
343
+ />
344
+ <Row
345
+ label="Deferred Synced"
346
+ value={`${sync.deferredSynced} / ${sync.deferredTotal}`}
347
+ />
348
+ {promoted.length > 0 && (
349
+ <Row label="Promoted to Deferred" value={promoted.join(', ')} warn />
350
+ )}
351
+ {(pendingCritical.length > 0 || pendingDeferred.length > 0) && (
352
+ <PendingInformers sync={sync} />
353
+ )}
354
+ </>
355
+ )}
322
356
  {inf.watchedCRDs && inf.watchedCRDs.length > 0 && (
323
357
  <Row label="Watched CRDs" value={inf.watchedCRDs.join(', ')} />
324
358
  )}
@@ -326,6 +360,53 @@ function InformersSection({ data }: { data: DiagnosticsSnapshot }) {
326
360
  )
327
361
  }
328
362
 
363
+ function PendingInformers({ sync }: { sync: DiagCacheSyncStatus }) {
364
+ const pending = getPendingInformers(sync)
365
+ if (pending.length === 0) return null
366
+ return (
367
+ <div className="mt-1.5 pt-1.5 border-t border-theme-border-light">
368
+ <span className="text-[10px] text-theme-text-tertiary uppercase">Pending Informers ({pending.length})</span>
369
+ {pending.map((i: DiagInformerSyncStatus) => (
370
+ <Row
371
+ key={i.kind}
372
+ label={`${i.kind} (${i.deferred ? 'deferred' : 'critical'})`}
373
+ value={`${i.items.toLocaleString()} items so far`}
374
+ warn={!i.deferred}
375
+ />
376
+ ))}
377
+ </div>
378
+ )
379
+ }
380
+
381
+ function getPendingInformers(sync: DiagCacheSyncStatus): DiagInformerSyncStatus[] {
382
+ const pendingNames = new Set([
383
+ ...(sync.pendingCritical ?? []),
384
+ ...(sync.pendingDeferred ?? []),
385
+ ])
386
+ return sync.informers
387
+ .filter((i) => pendingNames.has(i.kind))
388
+ .sort((a, b) => Number(a.deferred) - Number(b.deferred) || a.kind.localeCompare(b.kind))
389
+ }
390
+
391
+ function formatSyncPhase(phase: DiagSyncPhase): string {
392
+ switch (phase) {
393
+ case 'not_started': return 'not started'
394
+ case 'syncing_critical': return 'syncing critical'
395
+ case 'syncing_deferred': return 'syncing deferred'
396
+ case 'complete': return 'complete'
397
+ }
398
+ }
399
+
400
+ function formatElapsed(sec: number): string {
401
+ const s = Math.max(0, sec)
402
+ if (s < 1) return `${Math.round(s * 1000)}ms`
403
+ if (s < 60) return `${s.toFixed(1)}s`
404
+ const total = Math.round(s)
405
+ const m = Math.floor(total / 60)
406
+ const rem = total - m * 60
407
+ return `${m}m ${rem}s`
408
+ }
409
+
329
410
  function PrometheusSection({ data }: { data: DiagnosticsSnapshot }) {
330
411
  if (!data.prometheus) return null
331
412
  const p = data.prometheus
@@ -381,6 +462,73 @@ function APIDiscoverySection({ data }: { data: DiagnosticsSnapshot }) {
381
462
  )
382
463
  }
383
464
 
465
+ function PerfSection({ data }: { data: DiagnosticsSnapshot }) {
466
+ const backend = data.perf
467
+ const frontend = getK8sUIPerfSnapshot()
468
+ if (!backend && frontend.totalLayouts === 0 && frontend.totalStructureKeyComputes === 0) return null
469
+ // Warn when SSE has dropped frames, the topology payload window's p95 exceeds
470
+ // 5 MB, or the frontend ELK layout p95 exceeds 1s — these are the load-bearing
471
+ // thresholds for "the tab is going to feel bad."
472
+ const warn =
473
+ (backend?.sse.totalDrops ?? 0) > 0 ||
474
+ (backend?.topology.payloadBytes.p95 ?? 0) > 5 * 1024 * 1024 ||
475
+ frontend.layoutMs.p95 > 1000
476
+ return (
477
+ <Section title="Performance" warn={warn}>
478
+ {backend && (
479
+ <>
480
+ <Row label="Topology Builds" value={backend.topology.totalBuilds.toLocaleString()} />
481
+ <Row label=" Duration" value={formatSampleDuration(backend.topology.durationUs)} />
482
+ <Row label=" Node Count" value={formatSampleCount(backend.topology.nodeCount)} />
483
+ <Row label=" Edge Count" value={formatSampleCount(backend.topology.edgeCount)} />
484
+ <Row label=" Payload" value={formatSampleBytes(backend.topology.payloadBytes)} warn={backend.topology.payloadBytes.p95 > 5 * 1024 * 1024} />
485
+ <Row label=" Estimated Nodes" value={formatSampleCount(backend.topology.estimatedNodes)} />
486
+ <Row label="SSE Broadcasts" value={backend.sse.totalBroadcasts.toLocaleString()} />
487
+ <Row label="SSE Drops" value={backend.sse.totalDrops.toLocaleString()} warn={backend.sse.totalDrops > 0} />
488
+ </>
489
+ )}
490
+ {(frontend.totalLayouts > 0 || frontend.totalStructureKeyComputes > 0) && (
491
+ <>
492
+ <Row label="Frontend Layouts" value={`${frontend.totalLayouts.toLocaleString()} (skipped ${frontend.totalLayoutsSkipped.toLocaleString()})`} />
493
+ <Row label=" ELK Duration" value={formatFrontendMs(frontend.layoutMs)} warn={frontend.layoutMs.p95 > 1000} />
494
+ <Row label=" Last Rendered" value={`${frontend.lastLayoutNodeCount.toLocaleString()} nodes / ${frontend.lastLayoutEdgeCount.toLocaleString()} edges`} />
495
+ <Row label="Frontend structureKey" value={`${frontend.totalStructureKeyComputes.toLocaleString()} computes`} />
496
+ <Row label=" Duration" value={formatFrontendUs(frontend.structureKeyUs)} />
497
+ </>
498
+ )}
499
+ </Section>
500
+ )
501
+ }
502
+
503
+ function formatSampleDuration(w: DiagSampleWindow): string {
504
+ if (w.count === 0) return 'no samples'
505
+ const ms = (us: number) => (us / 1000).toFixed(us < 1000 ? 2 : 1)
506
+ return `last ${ms(w.last)}ms · p50 ${ms(w.p50)} · p95 ${ms(w.p95)} · max ${ms(w.max)}ms (n=${w.count})`
507
+ }
508
+
509
+ function formatSampleCount(w: DiagSampleWindow): string {
510
+ if (w.count === 0) return 'no samples'
511
+ return `last ${w.last.toLocaleString()} · p50 ${w.p50.toLocaleString()} · p95 ${w.p95.toLocaleString()} · max ${w.max.toLocaleString()}`
512
+ }
513
+
514
+ function formatSampleBytes(w: DiagSampleWindow): string {
515
+ if (w.count === 0) return 'no samples'
516
+ const kb = (b: number) => b < 1024 * 1024 ? `${(b / 1024).toFixed(1)}KB` : `${(b / 1024 / 1024).toFixed(2)}MB`
517
+ return `last ${kb(w.last)} · p50 ${kb(w.p50)} · p95 ${kb(w.p95)} · max ${kb(w.max)}`
518
+ }
519
+
520
+ function formatFrontendMs(w: { count: number; last: number; p50: number; p95: number; max: number }): string {
521
+ if (w.count === 0) return 'no samples'
522
+ const fmt = (v: number) => v < 100 ? v.toFixed(1) : Math.round(v).toString()
523
+ return `last ${fmt(w.last)}ms · p50 ${fmt(w.p50)} · p95 ${fmt(w.p95)} · max ${fmt(w.max)}ms (n=${w.count})`
524
+ }
525
+
526
+ function formatFrontendUs(w: { count: number; last: number; p50: number; p95: number; max: number }): string {
527
+ if (w.count === 0) return 'no samples'
528
+ const fmt = (v: number) => v < 1000 ? `${v.toFixed(0)}μs` : `${(v / 1000).toFixed(2)}ms`
529
+ return `last ${fmt(w.last)} · p50 ${fmt(w.p50)} · p95 ${fmt(w.p95)} · max ${fmt(w.max)} (n=${w.count})`
530
+ }
531
+
384
532
  function RuntimeSection({ data }: { data: DiagnosticsSnapshot }) {
385
533
  if (!data.runtime) return null
386
534
  const rt = data.runtime
@@ -406,6 +554,7 @@ function ConfigSection({ data }: { data: DiagnosticsSnapshot }) {
406
554
  <Row label="History Limit" value={cfg.historyLimit.toLocaleString()} />
407
555
  <Row label="MCP Enabled" value={cfg.mcpEnabled ? 'Yes' : 'No'} />
408
556
  <Row label="Prometheus URL" value={cfg.hasPrometheusURL ? 'Set' : 'Auto-discover'} />
557
+ <Row label="Prometheus Headers" value={cfg.hasPrometheusHeaders ? 'Set' : 'None'} />
409
558
  </Section>
410
559
  )
411
560
  }
@@ -431,7 +580,7 @@ function CopyButton({ label, onClick, copied }: { label: string; onClick: () =>
431
580
 
432
581
  // --- GitHub-friendly formatting ---
433
582
 
434
- function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): string {
583
+ function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnapshot, includeRawJson = true): string {
435
584
  const lines: string[] = []
436
585
  lines.push(`## Radar Diagnostics`)
437
586
  lines.push(``)
@@ -512,6 +661,36 @@ function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): stri
512
661
  const inf = data.informers
513
662
  lines.push(`### Informers`)
514
663
  lines.push(`- Typed: ${inf.typedCount} | Dynamic: ${inf.dynamicCount}`)
664
+ if (inf.syncStatus) {
665
+ const sync = inf.syncStatus
666
+ lines.push(`- Sync Phase: \`${sync.phase}\` (${formatElapsed(sync.elapsedSec)})`)
667
+ lines.push(`- Critical: ${sync.criticalSynced}/${sync.criticalTotal} synced | Deferred: ${sync.deferredSynced}/${sync.deferredTotal} synced`)
668
+ if (sync.promotedKinds && sync.promotedKinds.length > 0) {
669
+ lines.push(`- **Promoted to Deferred:** ${sync.promotedKinds.join(', ')}`)
670
+ }
671
+ const pending = getPendingInformers(sync)
672
+ if (pending.length > 0) {
673
+ const parts = pending.map((i) => {
674
+ const flags = [i.deferred ? 'deferred' : 'critical', `${i.items.toLocaleString()} items`]
675
+ if (i.forbiddenSeen) flags.push('forbidden')
676
+ if (i.lastError) flags.push(`err: ${i.lastError}`)
677
+ return `${i.kind}(${flags.join(', ')})`
678
+ })
679
+ lines.push(`- **Pending:** ${parts.join(', ')}`)
680
+ }
681
+ // Synced informers that have since hit a watch error or 403 — a count of 0
682
+ // from one of these is a stale/forbidden lister, not an empty cluster.
683
+ const errored = sync.informers.filter((i) => !pending.includes(i) && (i.lastError || i.forbiddenSeen))
684
+ if (errored.length > 0) {
685
+ const parts = errored.map((i) => {
686
+ const flags: string[] = []
687
+ if (i.forbiddenSeen) flags.push('forbidden')
688
+ if (i.lastError) flags.push(`err: ${i.lastError}`)
689
+ return `${i.kind}(${flags.join(', ')})`
690
+ })
691
+ lines.push(`- **Informer errors:** ${parts.join(', ')}`)
692
+ }
693
+ }
515
694
  if (inf.watchedCRDs && inf.watchedCRDs.length > 0) {
516
695
  lines.push(`- CRDs: ${inf.watchedCRDs.join(', ')}`)
517
696
  }
@@ -548,6 +727,37 @@ function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): stri
548
727
  lines.push(``)
549
728
  }
550
729
 
730
+ if (data.perf || (frontendPerf && (frontendPerf.totalLayouts > 0 || frontendPerf.totalStructureKeyComputes > 0))) {
731
+ lines.push(`### Performance`)
732
+ if (data.perf) {
733
+ const p = data.perf
734
+ const fmtMs = (us: number) => (us / 1000).toFixed(us < 1000 ? 2 : 1)
735
+ const fmtKB = (b: number) => b < 1024 * 1024 ? `${(b / 1024).toFixed(1)}KB` : `${(b / 1024 / 1024).toFixed(2)}MB`
736
+ lines.push(`- Topology Builds: ${p.topology.totalBuilds.toLocaleString()}`)
737
+ if (p.topology.durationUs.count > 0) {
738
+ lines.push(` - Duration (ms): last ${fmtMs(p.topology.durationUs.last)} · p50 ${fmtMs(p.topology.durationUs.p50)} · p95 ${fmtMs(p.topology.durationUs.p95)} · max ${fmtMs(p.topology.durationUs.max)}`)
739
+ lines.push(` - Nodes: last ${p.topology.nodeCount.last} · p95 ${p.topology.nodeCount.p95} · max ${p.topology.nodeCount.max}`)
740
+ lines.push(` - Edges: last ${p.topology.edgeCount.last} · p95 ${p.topology.edgeCount.p95} · max ${p.topology.edgeCount.max}`)
741
+ lines.push(` - Payload: last ${fmtKB(p.topology.payloadBytes.last)} · p95 ${fmtKB(p.topology.payloadBytes.p95)} · max ${fmtKB(p.topology.payloadBytes.max)}`)
742
+ lines.push(` - Estimated Nodes: last ${p.topology.estimatedNodes.last} · p95 ${p.topology.estimatedNodes.p95}`)
743
+ }
744
+ lines.push(`- SSE: ${p.sse.totalBroadcasts.toLocaleString()} broadcasts, ${p.sse.totalDrops.toLocaleString()} drops`)
745
+ }
746
+ if (frontendPerf && (frontendPerf.totalLayouts > 0 || frontendPerf.totalStructureKeyComputes > 0)) {
747
+ const fmt = (v: number) => v < 100 ? v.toFixed(1) : Math.round(v).toString()
748
+ lines.push(`- Frontend Layouts: ${frontendPerf.totalLayouts.toLocaleString()} (${frontendPerf.totalLayoutsSkipped.toLocaleString()} skipped)`)
749
+ if (frontendPerf.layoutMs.count > 0) {
750
+ lines.push(` - ELK (ms): last ${fmt(frontendPerf.layoutMs.last)} · p50 ${fmt(frontendPerf.layoutMs.p50)} · p95 ${fmt(frontendPerf.layoutMs.p95)} · max ${fmt(frontendPerf.layoutMs.max)}`)
751
+ lines.push(` - Last rendered: ${frontendPerf.lastLayoutNodeCount.toLocaleString()} nodes / ${frontendPerf.lastLayoutEdgeCount.toLocaleString()} edges`)
752
+ }
753
+ if (frontendPerf.structureKeyUs.count > 0) {
754
+ const fmtUs = (v: number) => v < 1000 ? `${Math.round(v)}μs` : `${(v / 1000).toFixed(2)}ms`
755
+ lines.push(` - structureKey: ${frontendPerf.totalStructureKeyComputes.toLocaleString()} computes · p50 ${fmtUs(frontendPerf.structureKeyUs.p50)} · p95 ${fmtUs(frontendPerf.structureKeyUs.p95)} · max ${fmtUs(frontendPerf.structureKeyUs.max)}`)
756
+ }
757
+ }
758
+ lines.push(``)
759
+ }
760
+
551
761
  if (data.runtime) {
552
762
  const rt = data.runtime
553
763
  lines.push(`### Runtime`)
@@ -591,8 +801,8 @@ function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): stri
591
801
  return lines.join('\n')
592
802
  }
593
803
 
594
- function formatForBugReport(data: DiagnosticsSnapshot): string {
595
- const diagnostics = formatForGitHub(data, false)
804
+ function formatForBugReport(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnapshot): string {
805
+ const diagnostics = formatForGitHub(data, frontendPerf, false)
596
806
 
597
807
  const lines: string[] = []
598
808
  lines.push(`## Describe the bug`)
@@ -39,10 +39,10 @@ export function Markdown({ children, className }: MarkdownProps) {
39
39
  </a>
40
40
  ),
41
41
  ul: ({ children }) => (
42
- <ul className="list-disc list-inside my-2 space-y-1 text-theme-text-secondary">{children}</ul>
42
+ <ul className="list-disc list-outside pl-4 my-2 space-y-1 text-theme-text-secondary">{children}</ul>
43
43
  ),
44
44
  ol: ({ children }) => (
45
- <ol className="list-decimal list-inside my-2 space-y-1 text-theme-text-secondary">{children}</ol>
45
+ <ol className="list-decimal list-outside pl-4 my-2 space-y-1 text-theme-text-secondary">{children}</ol>
46
46
  ),
47
47
  li: ({ children }) => (
48
48
  <li className="leading-relaxed">{children}</li>
@@ -51,7 +51,7 @@ export function Markdown({ children, className }: MarkdownProps) {
51
51
  const isInline = !className
52
52
  if (isInline) {
53
53
  return (
54
- <code className="px-1.5 py-0.5 bg-theme-elevated rounded text-theme-text-primary font-mono text-[0.9em]">
54
+ <code className="inline-code">
55
55
  {children}
56
56
  </code>
57
57
  )