@skyhook-io/radar-app 1.17.0 → 1.17.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 (276) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +125 -87
  4. package/src/api/actions.errorCode.test.ts +40 -0
  5. package/src/api/actions.test.ts +48 -0
  6. package/src/api/actions.ts +79 -0
  7. package/src/api/apiResources.navigation.test.tsx +232 -0
  8. package/src/api/apiResources.ts +46 -3
  9. package/src/api/client.helmSource.test.ts +39 -0
  10. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  11. package/src/api/client.namespaceSwitch.test.ts +107 -0
  12. package/src/api/client.ts +266 -39
  13. package/src/api/cnpg-ha.live.test.ts +140 -0
  14. package/src/api/cnpg-ha.test.ts +78 -0
  15. package/src/api/cnpg-ha.ts +19 -0
  16. package/src/api/cnpg-history.ts +123 -0
  17. package/src/api/cnpg-inspect.ts +102 -0
  18. package/src/api/cnpg-protection.ts +112 -0
  19. package/src/api/cnpg-recovery.test.tsx +60 -0
  20. package/src/api/cnpg-recovery.ts +262 -0
  21. package/src/api/cnpg-sessions.ts +175 -0
  22. package/src/api/cnpg-storage.ts +138 -0
  23. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  24. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  25. package/src/api/cnpg.ts +428 -10
  26. package/src/api/radarFeatures.render.test.tsx +47 -3
  27. package/src/api/radarFeatures.ts +4 -1
  28. package/src/api/reviewedActionMutation.test.tsx +164 -0
  29. package/src/api/reviewedActionMutation.ts +66 -0
  30. package/src/api/timelineSource.test.ts +9 -0
  31. package/src/api/timelineSource.ts +8 -0
  32. package/src/components/audit/AuditView.tsx +9 -0
  33. package/src/components/capacity/CapacityActivity.tsx +3 -31
  34. package/src/components/capacity/CapacityDemand.tsx +4 -36
  35. package/src/components/capacity/CapacityOverview.tsx +217 -56
  36. package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
  37. package/src/components/capacity/CapacityView.test.tsx +323 -0
  38. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  39. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  40. package/src/components/capacity/routes.ts +42 -0
  41. package/src/components/capacity/shared.tsx +11 -129
  42. package/src/components/capacity/workspaceRoute.ts +18 -0
  43. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  44. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  45. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  46. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  47. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  48. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  49. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  50. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  51. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  52. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  53. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  54. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  55. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  56. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  57. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  58. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  59. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  60. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  61. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  62. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  63. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  64. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  65. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  66. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  67. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  68. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  69. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  70. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  71. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  72. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  73. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  74. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  75. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  76. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  77. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  78. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  79. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  80. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  81. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  82. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  83. package/src/components/cnpg/CNPGProtection.tsx +228 -122
  84. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  85. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  86. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  87. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  88. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  89. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  90. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  91. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  92. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  93. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  94. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  95. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  96. package/src/components/cnpg/CNPGView.tsx +34 -21
  97. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  98. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  99. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  100. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  101. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  102. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  103. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  104. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  105. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  106. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  107. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  108. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  109. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  110. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  111. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  112. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  113. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  114. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  115. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  116. package/src/components/cnpg/actions/actionModel.ts +127 -0
  117. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  118. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  119. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  120. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  121. package/src/components/cnpg/archivingRepair.ts +145 -0
  122. package/src/components/cnpg/baseBackup.test.ts +36 -0
  123. package/src/components/cnpg/baseBackup.ts +36 -0
  124. package/src/components/cnpg/blocking.test.ts +59 -0
  125. package/src/components/cnpg/blocking.ts +131 -0
  126. package/src/components/cnpg/coverageText.test.ts +27 -0
  127. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  128. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  129. package/src/components/cnpg/creation/targetModel.ts +149 -0
  130. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  131. package/src/components/cnpg/fleetStatus.ts +43 -0
  132. package/src/components/cnpg/formFields.ts +2 -0
  133. package/src/components/cnpg/grantText.test.tsx +11 -0
  134. package/src/components/cnpg/host.test.tsx +24 -0
  135. package/src/components/cnpg/host.tsx +101 -0
  136. package/src/components/cnpg/inspectModel.test.ts +120 -0
  137. package/src/components/cnpg/inspectModel.ts +140 -0
  138. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  139. package/src/components/cnpg/logicalSlots.ts +29 -0
  140. package/src/components/cnpg/lsn.test.ts +40 -0
  141. package/src/components/cnpg/lsn.ts +43 -0
  142. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  143. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  144. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  145. package/src/components/cnpg/operations/model.test.ts +510 -0
  146. package/src/components/cnpg/operations/model.ts +499 -0
  147. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  148. package/src/components/cnpg/operations/presentation.ts +84 -0
  149. package/src/components/cnpg/operations/store.ts +98 -0
  150. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  151. package/src/components/cnpg/operatorStatus.ts +194 -0
  152. package/src/components/cnpg/paths.test.ts +54 -0
  153. package/src/components/cnpg/paths.ts +47 -4
  154. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  155. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  156. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  157. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  158. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  159. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  160. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  161. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  162. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  163. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  164. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  165. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  166. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  167. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  168. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  169. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  172. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  173. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  174. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  175. package/src/components/cnpg/refresh.test.ts +38 -0
  176. package/src/components/cnpg/refresh.ts +17 -0
  177. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  178. package/src/components/cnpg/routes.test.ts +34 -3
  179. package/src/components/cnpg/routes.ts +60 -48
  180. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  181. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  182. package/src/components/cnpg/runtimeModel.ts +196 -0
  183. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  184. package/src/components/cnpg/runtimeParts.tsx +79 -0
  185. package/src/components/cnpg/shared.test.tsx +59 -0
  186. package/src/components/cnpg/shared.tsx +100 -182
  187. package/src/components/cnpg/storageModel.test.ts +107 -0
  188. package/src/components/cnpg/storageModel.ts +101 -0
  189. package/src/components/cnpg/trendSamples.test.ts +148 -0
  190. package/src/components/cnpg/trendSamples.ts +222 -0
  191. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  192. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  193. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  194. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  195. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  196. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  197. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  198. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  199. package/src/components/cnpg/workspaceRoute.ts +23 -0
  200. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  201. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  202. package/src/components/cost/CostView.test.ts +2 -1
  203. package/src/components/cost/CostView.tsx +0 -2
  204. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  205. package/src/components/cost/errors.ts +3 -1
  206. package/src/components/cost/source.test.ts +10 -0
  207. package/src/components/cost/source.ts +5 -1
  208. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  209. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  210. package/src/components/dock/BottomDock.tsx +2 -0
  211. package/src/components/dock/TerminalTab.test.tsx +17 -0
  212. package/src/components/dock/TerminalTab.tsx +7 -3
  213. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  214. package/src/components/gitops/GitOpsView.tsx +16 -23
  215. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  216. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  217. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  218. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  219. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  220. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  221. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  222. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  223. package/src/components/helm/ValuesViewer.tsx +169 -78
  224. package/src/components/home/ActivitySummary.tsx +2 -1
  225. package/src/components/home/HomeView.tsx +20 -9
  226. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  227. package/src/components/home/TopologyPreview.tsx +2 -2
  228. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  229. package/src/components/issues/IssuesPane.test.ts +65 -1
  230. package/src/components/issues/IssuesPane.tsx +188 -25
  231. package/src/components/issues/uncheckedSources.ts +21 -0
  232. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  233. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  234. package/src/components/resource/PVCUsageBar.tsx +15 -6
  235. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  236. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  237. package/src/components/resources/CompositeRenderer.tsx +4 -4
  238. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  239. package/src/components/resources/ResourcesView.tsx +26 -12
  240. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  241. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  242. package/src/components/settings/SettingsDialog.tsx +28 -15
  243. package/src/components/timeline/TimelineList.tsx +22 -24
  244. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  245. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  246. package/src/components/timeline/TimelineView.tsx +18 -45
  247. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  248. package/src/components/timeline/useStickyError.test.tsx +47 -0
  249. package/src/components/timeline/useStickyError.ts +15 -0
  250. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  251. package/src/components/ui/Markdown.test.tsx +65 -0
  252. package/src/components/ui/Markdown.tsx +144 -127
  253. package/src/components/whats-new/releaseNotes.ts +63 -1
  254. package/src/components/workload/WorkloadView.test.ts +10 -1
  255. package/src/components/workload/WorkloadView.tsx +27 -239
  256. package/src/components/workspace/layout.test.tsx +29 -0
  257. package/src/components/workspace/layout.tsx +259 -0
  258. package/src/components/workspace/table.ts +7 -0
  259. package/src/contexts/CapabilitiesContext.tsx +1 -1
  260. package/src/integrations/resourceHost.test.tsx +55 -0
  261. package/src/integrations/resourceHost.ts +99 -0
  262. package/src/integrations/resourceHosts.test.tsx +141 -0
  263. package/src/integrations/resourceHosts.tsx +119 -0
  264. package/src/integrations/workspaceRoute.ts +20 -0
  265. package/src/integrations/workspaceRoutes.test.ts +40 -0
  266. package/src/integrations/workspaceRoutes.ts +22 -0
  267. package/src/integrations/workspaceScreens.tsx +14 -0
  268. package/src/utils/drawer-trail.ts +35 -0
  269. package/src/utils/kindFilterStore.test.ts +147 -0
  270. package/src/utils/kindFilterStore.ts +91 -0
  271. package/src/utils/navigation.test.ts +13 -1
  272. package/src/utils/navigation.ts +39 -2
  273. package/src/utils/page-links.ts +21 -0
  274. package/src/utils/tabStorage.ts +40 -0
  275. package/src/utils/viewMemory.test.ts +78 -0
  276. package/src/utils/viewMemory.ts +54 -0
@@ -1,7 +1,7 @@
1
1
  import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
2
2
  import { useNavigate, useSearchParams } from 'react-router-dom'
3
3
  import type { ReactNode } from 'react'
4
- import { Network, AlertTriangle, RefreshCw, Boxes, X } from 'lucide-react'
4
+ import { Network, AlertTriangle, Boxes, X } from 'lucide-react'
5
5
  import {
6
6
  clampLensToSelection,
7
7
  deriveLiveSelection,
@@ -21,6 +21,9 @@ import {
21
21
  useDebouncedValue,
22
22
  } from '@skyhook-io/k8s-ui'
23
23
  import { TimelineList } from './TimelineList'
24
+ import { TimelineLoadError, TimelineStaleNote } from './TimelineLoadError'
25
+ import { ViewModeToggle } from './ViewModeToggle'
26
+ import { useStickyError } from './useStickyError'
24
27
  import type { ActivityFilterKey } from './TimelineList'
25
28
  import { TimelineSwimlanes } from './TimelineSwimlanes'
26
29
  import { RetainedTimelineScrubber, extendSelection, type ScrubberDomainInfo } from './RetainedTimelineScrubber'
@@ -275,6 +278,7 @@ interface TimelineViewProps {
275
278
  requiresNamespaceFilter?: boolean
276
279
  availableNamespaces?: { name: string }[]
277
280
  onNamespaceSelect?: (ns: string) => void
281
+ onOpenDiagnostics?: () => void
278
282
  }
279
283
 
280
284
  export function resolveApplicationTimelineScope(searchParams: URLSearchParams, namespaces: string[]) {
@@ -293,7 +297,7 @@ export function resolveApplicationTimelineScope(searchParams: URLSearchParams, n
293
297
  }
294
298
  }
295
299
 
296
- export function TimelineView({ namespaces, onResourceClick, initialViewMode, initialFilter, initialTimeRange, requiresNamespaceFilter, availableNamespaces, onNamespaceSelect }: TimelineViewProps) {
300
+ export function TimelineView({ namespaces, onResourceClick, initialViewMode, initialFilter, initialTimeRange, requiresNamespaceFilter, availableNamespaces, onNamespaceSelect, onOpenDiagnostics }: TimelineViewProps) {
297
301
  // URL is the source of truth for every control below (deep-linkable +
298
302
  // back/forward-restorable). Read on mount, written on user change.
299
303
  const [searchParams, setSearchParams] = useSearchParams()
@@ -683,7 +687,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
683
687
  // Fetch all activity - zoom controls what's visible in the UI. This ring feeds
684
688
  // the swimlanes and the local strip's histogram, so it also runs in list mode
685
689
  // when that strip is shown; the list itself fetches its own 2000.
686
- const { data: activity, isLoading, isError, error, refetch, truncated: ringTruncated } = timelineSource.useEvents({
690
+ const { data: activity, isLoading, isFetching, isError, error, refetch, truncated: ringTruncated } = timelineSource.useEvents({
687
691
  namespaces: timelineNamespaces,
688
692
  timeRange: 'all',
689
693
  includeK8sEvents: true,
@@ -701,6 +705,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
701
705
  fromMs: isRetained ? selection.fromMs : undefined,
702
706
  toMs: isRetained ? selection.toMs : undefined,
703
707
  })
708
+ const loadError = useStickyError(error, activity != null)
704
709
 
705
710
  // Topology powers both swimlane hierarchy and application-scoped attribution.
706
711
  const { data: rawTopology } = useTopology(timelineNamespaces, 'resources', {
@@ -869,13 +874,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
869
874
  {/* Failing background polls with a loaded ring: keep the data, say
870
875
  it's going stale. The full-screen error is reserved for no-data. */}
871
876
  {isError && activity && (
872
- <div className="flex items-center gap-1.5 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-tertiary">
873
- <AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${SEVERITY_TEXT.warning}`} />
874
- Live updates are failing — the timeline may be stale.
875
- <button type="button" onClick={() => refetch()} className="underline hover:text-theme-text-primary">
876
- Retry now
877
- </button>
878
- </div>
877
+ <TimelineStaleNote error={error} subject="timeline" onRetry={() => refetch()} />
879
878
  )}
880
879
  {isRetained ? (
881
880
  <RetainedTimelineScrubber
@@ -967,29 +966,23 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
967
966
  // a load failure. With a loaded ring on screen, a failing background poll
968
967
  // must NOT blank it (gate on data before error); the stale-data banner
969
968
  // below carries the warning instead.
970
- if (isError && !activity) {
969
+ if (loadError && !activity) {
971
970
  // Surface the server's own message — a generic "failed to load" would
972
971
  // swallow whatever the hub said. "Try again" is a full resync: the
973
972
  // retained source drops its delta cursor and reloads the whole ring.
974
- const detail = error?.message?.trim()
975
973
  return wrap(
976
974
  <div className="flex-1 flex flex-col">
977
975
  <div className="flex items-center justify-between px-4 py-2 border-b border-theme-border">
978
976
  <div />
979
977
  <ViewModeToggle viewMode={viewMode} onViewModeChange={setViewMode} />
980
978
  </div>
981
- <div className="flex-1 flex flex-col items-center justify-center text-theme-text-tertiary gap-3 px-6">
982
- <AlertTriangle className="w-10 h-10 text-amber-400/70" />
983
- <p className="text-base">Failed to load timeline data</p>
984
- {detail && <p className="max-w-md text-center text-sm text-theme-text-tertiary">{detail}</p>}
985
- <button
986
- onClick={() => refetch()}
987
- className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg hover:bg-theme-hover transition-colors"
988
- >
989
- <RefreshCw className="w-3.5 h-3.5" />
990
- Try again
991
- </button>
992
- </div>
979
+ <TimelineLoadError
980
+ className="flex-1"
981
+ error={loadError}
982
+ retrying={isFetching}
983
+ onRetry={() => refetch()}
984
+ onOpenDiagnostics={onOpenDiagnostics}
985
+ />
993
986
  </div>
994
987
  )
995
988
  }
@@ -1064,27 +1057,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
1064
1057
  appScoped={Boolean(focusedAppKey)}
1065
1058
  topology={stableTopology}
1066
1059
  appScopeLoading={focusedAppLoading}
1060
+ onOpenDiagnostics={onOpenDiagnostics}
1067
1061
  />
1068
1062
  )
1069
1063
  }
1070
-
1071
- function ViewModeToggle({ viewMode, onViewModeChange }: { viewMode: TimelineViewMode, onViewModeChange: (mode: TimelineViewMode) => void }) {
1072
- return (
1073
- <div className="flex items-center gap-1 bg-theme-base rounded-lg p-0.5 border border-theme-border">
1074
- <button
1075
- type="button"
1076
- onClick={() => onViewModeChange('list')}
1077
- className={`px-2 py-1 text-xs rounded-md transition-colors ${viewMode === 'list' ? 'bg-theme-surface text-theme-text-primary shadow-sm' : 'text-theme-text-secondary hover:text-theme-text-primary'}`}
1078
- >
1079
- List
1080
- </button>
1081
- <button
1082
- type="button"
1083
- onClick={() => onViewModeChange('swimlane')}
1084
- className={`px-2 py-1 text-xs rounded-md transition-colors ${viewMode === 'swimlane' ? 'bg-theme-surface text-theme-text-primary shadow-sm' : 'text-theme-text-secondary hover:text-theme-text-primary'}`}
1085
- >
1086
- Swimlane
1087
- </button>
1088
- </div>
1089
- )
1090
- }
@@ -0,0 +1,20 @@
1
+ export function ViewModeToggle({ viewMode, onViewModeChange }: { viewMode: 'list' | 'swimlane', onViewModeChange: (mode: 'list' | 'swimlane') => void }) {
2
+ return (
3
+ <div className="flex items-center gap-1 bg-theme-base rounded-lg p-0.5 border border-theme-border">
4
+ <button
5
+ type="button"
6
+ onClick={() => onViewModeChange('list')}
7
+ className={`px-2 py-1 text-xs rounded-md transition-colors ${viewMode === 'list' ? 'bg-theme-surface text-theme-text-primary shadow-sm' : 'text-theme-text-secondary hover:text-theme-text-primary'}`}
8
+ >
9
+ List
10
+ </button>
11
+ <button
12
+ type="button"
13
+ onClick={() => onViewModeChange('swimlane')}
14
+ className={`px-2 py-1 text-xs rounded-md transition-colors ${viewMode === 'swimlane' ? 'bg-theme-surface text-theme-text-primary shadow-sm' : 'text-theme-text-secondary hover:text-theme-text-primary'}`}
15
+ >
16
+ Swimlane
17
+ </button>
18
+ </div>
19
+ )
20
+ }
@@ -0,0 +1,47 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { useStickyError } from './useStickyError'
6
+
7
+ const seen: (Error | null)[] = []
8
+ function Harness({ error, hasData }: { error: Error | null; hasData: boolean }) {
9
+ seen.push(useStickyError(error, hasData))
10
+ return null
11
+ }
12
+
13
+ let root: Root
14
+ let container: HTMLDivElement
15
+ beforeEach(() => {
16
+ seen.length = 0
17
+ container = document.createElement('div')
18
+ root = createRoot(container)
19
+ })
20
+ afterEach(() => act(() => root.unmount()))
21
+ const render = (error: Error | null, hasData: boolean) => act(() => root.render(<Harness error={error} hasData={hasData} />))
22
+ const last = () => seen[seen.length - 1]
23
+
24
+ describe('useStickyError', () => {
25
+ it('keeps the error while a refetch with no data clears it', () => {
26
+ const down = new Error('database down')
27
+ render(down, false)
28
+ expect(last()).toBe(down)
29
+ render(null, false)
30
+ expect(last()).toBe(down)
31
+ })
32
+
33
+ it('drops the error once data arrives', () => {
34
+ render(new Error('database down'), false)
35
+ render(null, true)
36
+ expect(last()).toBeNull()
37
+ })
38
+
39
+ // A page with data on screen whose poll starts failing: the hook must settle,
40
+ // not bounce between the error and null until React gives up.
41
+ it('settles when an error arrives while data is on screen', () => {
42
+ render(null, true)
43
+ render(new Error('database down'), true)
44
+ expect(last()).toBeNull()
45
+ expect(seen.length).toBeLessThan(10)
46
+ })
47
+ })
@@ -0,0 +1,15 @@
1
+ import { useState } from 'react'
2
+
3
+ // React Query clears the error of a query that has never loaded each time it
4
+ // refetches, so a failure that persists across polls would flicker to a loader
5
+ // on every interval. This keeps the last error on screen until data arrives.
6
+ // Once data is loaded it returns null: a failing poll then belongs to the
7
+ // stale-data banner, not the full-screen error.
8
+ export function useStickyError(error: Error | null | undefined, hasData: boolean): Error | null {
9
+ const [shown, setShown] = useState<Error | null>(null)
10
+ const next = hasData ? null : (error ?? shown)
11
+ // Adjusting state during render: converges in one extra pass because `next`
12
+ // is a fixed point once stored.
13
+ if (next !== shown) setShown(next)
14
+ return next
15
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, useState, useCallback } from 'react'
1
+ import { useEffect, useState, useCallback, useRef } from 'react'
2
2
  import { X, Copy, Check, ExternalLink } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { TRANSITION_BACKDROP, TRANSITION_PANEL, overlayTransitionStyle } from '../../utils/animation'
@@ -7,15 +7,29 @@ import { useDiagnostics } from '../../api/client'
7
7
  import type { DiagnosticsSnapshot, DiagEnvVar, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase, DiagSampleWindow } from '../../api/client'
8
8
  import { getK8sUIPerfSnapshot, type K8sUIPerfSnapshot } from '@skyhook-io/k8s-ui'
9
9
 
10
+ export type DiagnosticsFocus = 'timeline'
11
+
10
12
  interface DiagnosticsOverlayProps {
11
13
  onClose: () => void
12
14
  isOpen?: boolean
15
+ /** Section to scroll to on open, for callers linking to one specific problem. */
16
+ focus?: DiagnosticsFocus
13
17
  }
14
18
 
15
- export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverlayProps) {
19
+ export function DiagnosticsOverlay({ onClose, isOpen = true, focus }: DiagnosticsOverlayProps) {
16
20
  const { data, isLoading, error } = useDiagnostics(true)
17
21
  const [copied, setCopied] = useState<'json' | 'formatted' | null>(null)
18
22
  const [reportOpened, setReportOpened] = useState(false)
23
+ const timelineRef = useRef<HTMLDivElement>(null)
24
+ const hasData = data != null
25
+
26
+ // Once, when the snapshot first renders: the overlay polls, and re-scrolling
27
+ // on every refresh would yank the reader back while they read other sections.
28
+ useEffect(() => {
29
+ if (isOpen && hasData && focus === 'timeline') {
30
+ timelineRef.current?.scrollIntoView({ block: 'start' })
31
+ }
32
+ }, [isOpen, hasData, focus])
19
33
 
20
34
  // Close on Escape (capture phase) — only while logically open: the overlay
21
35
  // stays mounted through its exit.
@@ -119,6 +133,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
119
133
  <CacheSection data={data} />
120
134
  <MetricsSection data={data} />
121
135
  <EventPipelineSection data={data} />
136
+ <div ref={timelineRef} className="scroll-mt-4"><TimelineSection data={data} /></div>
122
137
  <InformersSection data={data} />
123
138
  <PrometheusSection data={data} />
124
139
  <TrafficSection data={data} />
@@ -330,6 +345,20 @@ function EventPipelineSection({ data }: { data: DiagnosticsSnapshot }) {
330
345
  )
331
346
  }
332
347
 
348
+ function TimelineSection({ data }: { data: DiagnosticsSnapshot }) {
349
+ if (!data.timeline) return null
350
+ const t = data.timeline
351
+ return (
352
+ <Section title="Timeline" warn={t.degraded}>
353
+ <Row label="Storage" value={t.storageType} warn={t.degraded} />
354
+ {t.degraded && t.degradedReason && <Row label="Unavailable" value={t.degradedReason} warn />}
355
+ {/* An unreachable database has no count to report; 0 would read as empty. */}
356
+ {(!t.degraded || t.storageType === 'memory') && <Row label="Events" value={t.totalEvents.toLocaleString()} />}
357
+ <Row label="Dropped" value={t.totalDrops.toLocaleString()} warn={t.totalDrops > 0} />
358
+ </Section>
359
+ )
360
+ }
361
+
333
362
  function InformersSection({ data }: { data: DiagnosticsSnapshot }) {
334
363
  if (!data.informers) return null
335
364
  const inf = data.informers
@@ -702,6 +731,9 @@ export function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIP
702
731
  const t = data.timeline
703
732
  lines.push(`### Timeline`)
704
733
  lines.push(`- Storage: \`${t.storageType}\` | Events: ${t.totalEvents.toLocaleString()} | Errors: ${t.storeErrors} | Drops: ${t.totalDrops}`)
734
+ if (t.degraded && t.degradedReason) {
735
+ lines.push(`- Unavailable: ${t.degradedReason}`)
736
+ }
705
737
  lines.push(``)
706
738
  }
707
739
 
@@ -0,0 +1,65 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { Markdown } from './Markdown'
6
+
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+
9
+ const TEXT = 'First paragraph.\n\nRun `kubectl get pods` then open [the docs](https://example.com).\n\n```\nkubectl describe pod x\n```'
10
+
11
+ let container: HTMLDivElement
12
+ let root: Root
13
+
14
+ beforeEach(() => {
15
+ container = document.createElement('div')
16
+ document.body.appendChild(container)
17
+ root = createRoot(container)
18
+ })
19
+
20
+ afterEach(() => {
21
+ act(() => root.unmount())
22
+ container.remove()
23
+ })
24
+
25
+ // Callers pass fresh inline callbacks on every render, as the investigation
26
+ // views do, so the rendered elements must survive a re-render with new ones.
27
+ function render(label: string) {
28
+ act(() => {
29
+ root.render(
30
+ <Markdown
31
+ codeActions={(code) => <button type="button">{`${label}:${code}`}</button>}
32
+ linkRenderer={(href, children) => (href?.includes('example') ? <a data-custom={label}>{children}</a> : null)}
33
+ >
34
+ {TEXT}
35
+ </Markdown>,
36
+ )
37
+ })
38
+ }
39
+
40
+ describe('Markdown', () => {
41
+ it('keeps its rendered elements across parent re-renders', () => {
42
+ render('a')
43
+ const paragraph = container.querySelector('p')
44
+ const pre = container.querySelector('pre')
45
+ expect(paragraph).not.toBeNull()
46
+ expect(pre).not.toBeNull()
47
+
48
+ render('b')
49
+
50
+ expect(container.querySelector('p')).toBe(paragraph)
51
+ expect(container.querySelector('pre')).toBe(pre)
52
+ expect(paragraph?.isConnected).toBe(true)
53
+ })
54
+
55
+ it('applies the latest link and code hooks', () => {
56
+ render('a')
57
+ render('b')
58
+
59
+ expect(container.querySelector('a')?.getAttribute('data-custom')).toBe('b')
60
+ const labels = Array.from(container.querySelectorAll('button')).map((b) => b.textContent?.trim())
61
+ expect(labels).toContain('b:kubectl get pods')
62
+ expect(labels).toContain('b:kubectl describe pod x')
63
+ expect(labels.some((l) => l?.startsWith('a:'))).toBe(false)
64
+ })
65
+ })
@@ -1,12 +1,11 @@
1
- import ReactMarkdown from 'react-markdown'
1
+ import { createContext, useContext, useMemo } from 'react'
2
+ import ReactMarkdown, { type Components } from 'react-markdown'
2
3
  import remarkGfm from 'remark-gfm'
3
4
  import { clsx } from 'clsx'
4
5
 
5
6
  import type { ReactNode } from 'react'
6
7
 
7
- interface MarkdownProps {
8
- children: string
9
- className?: string
8
+ interface MarkdownHooks {
10
9
  /**
11
10
  * Intercepts links before the default anchor renders. Return null to fall
12
11
  * through. Lets a caller turn a reserved href (an in-page evidence
@@ -16,132 +15,150 @@ interface MarkdownProps {
16
15
  codeActions?: (code: string) => ReactNode
17
16
  }
18
17
 
18
+ interface MarkdownProps extends MarkdownHooks {
19
+ children: string
20
+ className?: string
21
+ }
22
+
23
+ const MarkdownHooksContext = createContext<MarkdownHooks>({})
24
+
25
+ const REMARK_PLUGINS = [remarkGfm]
26
+
27
+ // Module-level so every render hands react-markdown the same component types.
28
+ // A fresh map per render makes React unmount and remount the whole rendered
29
+ // tree whenever the parent re-renders; WebKit then moves the scroll position
30
+ // of any pane the text sits in. Per-call hooks arrive through context instead.
31
+ const COMPONENTS: Components = {
32
+ h1: ({ children }) => (
33
+ <h1 className="text-lg font-bold text-theme-text-primary mt-4 mb-2 first:mt-0">{children}</h1>
34
+ ),
35
+ h2: ({ children }) => (
36
+ <h2 className="text-base font-semibold text-theme-text-primary mt-4 mb-2 first:mt-0">{children}</h2>
37
+ ),
38
+ h3: ({ children }) => (
39
+ <h3 className="text-sm font-semibold text-theme-text-primary mt-3 mb-1.5">{children}</h3>
40
+ ),
41
+ h4: ({ children }) => (
42
+ <h4 className="text-sm font-medium text-theme-text-primary mt-2 mb-1">{children}</h4>
43
+ ),
44
+ p: ({ children }) => (
45
+ <p className="text-theme-text-secondary my-2 leading-relaxed">{children}</p>
46
+ ),
47
+ a: function MarkdownLink({ href, children }) {
48
+ const { linkRenderer } = useContext(MarkdownHooksContext)
49
+ const custom = linkRenderer?.(href, children)
50
+ if (custom !== null && custom !== undefined) return <>{custom}</>
51
+ return (
52
+ <a
53
+ href={href}
54
+ target="_blank"
55
+ rel="noopener noreferrer"
56
+ className="text-blue-400 hover:text-blue-300 underline underline-offset-2"
57
+ >
58
+ {children}
59
+ </a>
60
+ )
61
+ },
62
+ ul: ({ children }) => (
63
+ <ul className="list-disc list-outside pl-4 my-2 space-y-1 text-theme-text-secondary">{children}</ul>
64
+ ),
65
+ ol: ({ children }) => (
66
+ <ol className="list-decimal list-outside pl-4 my-2 space-y-1 text-theme-text-secondary">{children}</ol>
67
+ ),
68
+ li: ({ children }) => (
69
+ <li className="leading-relaxed">{children}</li>
70
+ ),
71
+ code: function MarkdownCode({ className, children }) {
72
+ const { codeActions } = useContext(MarkdownHooksContext)
73
+ const isInline = !className
74
+ if (isInline) {
75
+ const actions = codeActions?.(String(children))
76
+ if (actions) {
77
+ return (
78
+ <span className="inline-flex max-w-full items-start gap-1 align-middle">
79
+ <code className="inline-code min-w-0">{children}</code>
80
+ {actions}
81
+ </span>
82
+ )
83
+ }
84
+ return (
85
+ <code className="inline-code">
86
+ {children}
87
+ </code>
88
+ )
89
+ }
90
+ return (
91
+ <code className="block font-mono text-theme-text-secondary">{children}</code>
92
+ )
93
+ },
94
+ pre: function MarkdownPre({ children, node }) {
95
+ const { codeActions } = useContext(MarkdownHooksContext)
96
+ const code = node?.children[0]
97
+ const text = code?.type === 'element'
98
+ ? code.children.filter(child => child.type === 'text').map(child => child.value).join('')
99
+ : ''
100
+ const actions = codeActions?.(text)
101
+ if (actions) {
102
+ // Render the code directly so an unlabelled fence does not also
103
+ // receive the inline-code action. Only the code scrolls, not Copy.
104
+ return (
105
+ <pre className="my-3 flex items-start gap-2 rounded-lg bg-theme-elevated p-3 text-xs font-mono text-theme-text-secondary">
106
+ <code className="min-w-0 flex-1 overflow-x-auto">{text}</code>
107
+ {actions}
108
+ </pre>
109
+ )
110
+ }
111
+ return (
112
+ <pre className="my-3 p-3 bg-theme-elevated rounded-lg overflow-x-auto text-xs font-mono text-theme-text-secondary">
113
+ {children}
114
+ </pre>
115
+ )
116
+ },
117
+ blockquote: ({ children }) => (
118
+ <blockquote className="border-l-2 border-theme-border pl-3 my-2 text-theme-text-tertiary italic">
119
+ {children}
120
+ </blockquote>
121
+ ),
122
+ table: ({ children }) => (
123
+ <div className="my-3 overflow-x-auto">
124
+ <table className="min-w-full border-collapse text-sm">{children}</table>
125
+ </div>
126
+ ),
127
+ thead: ({ children }) => (
128
+ <thead className="bg-theme-elevated">{children}</thead>
129
+ ),
130
+ tbody: ({ children }) => (
131
+ <tbody className="divide-y divide-theme-border">{children}</tbody>
132
+ ),
133
+ tr: ({ children }) => (
134
+ <tr className="border-b border-theme-border">{children}</tr>
135
+ ),
136
+ th: ({ children }) => (
137
+ <th className="px-3 py-2 text-left text-theme-text-primary font-medium border border-theme-border">
138
+ {children}
139
+ </th>
140
+ ),
141
+ td: ({ children }) => (
142
+ <td className="px-3 py-2 text-theme-text-secondary border border-theme-border">{children}</td>
143
+ ),
144
+ hr: () => <hr className="my-4 border-theme-border" />,
145
+ strong: ({ children }) => (
146
+ <strong className="font-semibold text-theme-text-primary">{children}</strong>
147
+ ),
148
+ em: ({ children }) => (
149
+ <em className="italic">{children}</em>
150
+ ),
151
+ }
152
+
19
153
  export function Markdown({ children, className, linkRenderer, codeActions }: MarkdownProps) {
154
+ const hooks = useMemo(() => ({ linkRenderer, codeActions }), [linkRenderer, codeActions])
20
155
  return (
21
156
  <div className={clsx('markdown-content', className)}>
22
- <ReactMarkdown
23
- remarkPlugins={[remarkGfm]}
24
- components={{
25
- h1: ({ children }) => (
26
- <h1 className="text-lg font-bold text-theme-text-primary mt-4 mb-2 first:mt-0">{children}</h1>
27
- ),
28
- h2: ({ children }) => (
29
- <h2 className="text-base font-semibold text-theme-text-primary mt-4 mb-2 first:mt-0">{children}</h2>
30
- ),
31
- h3: ({ children }) => (
32
- <h3 className="text-sm font-semibold text-theme-text-primary mt-3 mb-1.5">{children}</h3>
33
- ),
34
- h4: ({ children }) => (
35
- <h4 className="text-sm font-medium text-theme-text-primary mt-2 mb-1">{children}</h4>
36
- ),
37
- p: ({ children }) => (
38
- <p className="text-theme-text-secondary my-2 leading-relaxed">{children}</p>
39
- ),
40
- a: ({ href, children }) => {
41
- const custom = linkRenderer?.(href, children)
42
- if (custom !== null && custom !== undefined) return <>{custom}</>
43
- return (
44
- <a
45
- href={href}
46
- target="_blank"
47
- rel="noopener noreferrer"
48
- className="text-blue-400 hover:text-blue-300 underline underline-offset-2"
49
- >
50
- {children}
51
- </a>
52
- )
53
- },
54
- ul: ({ children }) => (
55
- <ul className="list-disc list-outside pl-4 my-2 space-y-1 text-theme-text-secondary">{children}</ul>
56
- ),
57
- ol: ({ children }) => (
58
- <ol className="list-decimal list-outside pl-4 my-2 space-y-1 text-theme-text-secondary">{children}</ol>
59
- ),
60
- li: ({ children }) => (
61
- <li className="leading-relaxed">{children}</li>
62
- ),
63
- code: ({ className, children }) => {
64
- const isInline = !className
65
- if (isInline) {
66
- const actions = codeActions?.(String(children))
67
- if (actions) {
68
- return (
69
- <span className="inline-flex max-w-full items-start gap-1 align-middle">
70
- <code className="inline-code min-w-0">{children}</code>
71
- {actions}
72
- </span>
73
- )
74
- }
75
- return (
76
- <code className="inline-code">
77
- {children}
78
- </code>
79
- )
80
- }
81
- return (
82
- <code className="block font-mono text-theme-text-secondary">{children}</code>
83
- )
84
- },
85
- pre: ({ children, node }) => {
86
- const code = node?.children[0]
87
- const text = code?.type === 'element'
88
- ? code.children.filter(child => child.type === 'text').map(child => child.value).join('')
89
- : ''
90
- const actions = codeActions?.(text)
91
- if (actions) {
92
- // Render the code directly so an unlabelled fence does not also
93
- // receive the inline-code action. Only the code scrolls, not Copy.
94
- return (
95
- <pre className="my-3 flex items-start gap-2 rounded-lg bg-theme-elevated p-3 text-xs font-mono text-theme-text-secondary">
96
- <code className="min-w-0 flex-1 overflow-x-auto">{text}</code>
97
- {actions}
98
- </pre>
99
- )
100
- }
101
- return (
102
- <pre className="my-3 p-3 bg-theme-elevated rounded-lg overflow-x-auto text-xs font-mono text-theme-text-secondary">
103
- {children}
104
- </pre>
105
- )
106
- },
107
- blockquote: ({ children }) => (
108
- <blockquote className="border-l-2 border-theme-border pl-3 my-2 text-theme-text-tertiary italic">
109
- {children}
110
- </blockquote>
111
- ),
112
- table: ({ children }) => (
113
- <div className="my-3 overflow-x-auto">
114
- <table className="min-w-full border-collapse text-sm">{children}</table>
115
- </div>
116
- ),
117
- thead: ({ children }) => (
118
- <thead className="bg-theme-elevated">{children}</thead>
119
- ),
120
- tbody: ({ children }) => (
121
- <tbody className="divide-y divide-theme-border">{children}</tbody>
122
- ),
123
- tr: ({ children }) => (
124
- <tr className="border-b border-theme-border">{children}</tr>
125
- ),
126
- th: ({ children }) => (
127
- <th className="px-3 py-2 text-left text-theme-text-primary font-medium border border-theme-border">
128
- {children}
129
- </th>
130
- ),
131
- td: ({ children }) => (
132
- <td className="px-3 py-2 text-theme-text-secondary border border-theme-border">{children}</td>
133
- ),
134
- hr: () => <hr className="my-4 border-theme-border" />,
135
- strong: ({ children }) => (
136
- <strong className="font-semibold text-theme-text-primary">{children}</strong>
137
- ),
138
- em: ({ children }) => (
139
- <em className="italic">{children}</em>
140
- ),
141
- }}
142
- >
143
- {children}
144
- </ReactMarkdown>
157
+ <MarkdownHooksContext.Provider value={hooks}>
158
+ <ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={COMPONENTS}>
159
+ {children}
160
+ </ReactMarkdown>
161
+ </MarkdownHooksContext.Provider>
145
162
  </div>
146
163
  )
147
164
  }