@skyhook-io/k8s-ui 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/README.md +41 -0
  2. package/package.json +6 -6
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +7 -3
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +142 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -81
  67. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  68. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  69. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +91 -2
  70. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +76 -76
  71. package/src/components/gitops/tree/layout.test.ts +111 -0
  72. package/src/components/gitops/tree/layout.ts +128 -0
  73. package/src/components/gitops/tree/tree-helpers.test.ts +23 -0
  74. package/src/components/gitops/tree/tree-helpers.ts +12 -2
  75. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  76. package/src/components/issues/IssuesView.tsx +44 -6
  77. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  78. package/src/components/issues/ResourceIssuesSection.tsx +8 -2
  79. package/src/components/issues/diagnostic.ts +2 -0
  80. package/src/components/issues/index.ts +3 -1
  81. package/src/components/issues/issue-timing.ts +12 -1
  82. package/src/components/issues/issues.test.ts +75 -1
  83. package/src/components/issues/severity.ts +30 -2
  84. package/src/components/issues/types.ts +55 -4
  85. package/src/components/logs/LogCore.theme.test.tsx +206 -3
  86. package/src/components/logs/LogCore.tsx +180 -53
  87. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  88. package/src/components/logs/LogsViewer.test.tsx +369 -0
  89. package/src/components/logs/LogsViewer.tsx +91 -13
  90. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  91. package/src/components/logs/StructuredLogLine.tsx +23 -31
  92. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  93. package/src/components/logs/WorkloadLogsViewer.tsx +46 -11
  94. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  95. package/src/components/logs/useLogBuffer.test.ts +8 -0
  96. package/src/components/logs/useLogBuffer.ts +48 -18
  97. package/src/components/logs/useLogSearch.ts +3 -1
  98. package/src/components/logs/useLogStream.ts +17 -1
  99. package/src/components/problems/index.ts +1 -0
  100. package/src/components/problems/problems.test.tsx +63 -0
  101. package/src/components/problems/problems.tsx +165 -0
  102. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  103. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  104. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  105. package/src/components/resources/ColumnPickerList.tsx +174 -0
  106. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  107. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  108. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  109. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  110. package/src/components/resources/ResourcesView.kind-view.test.tsx +296 -0
  111. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  112. package/src/components/resources/ResourcesView.tsx +392 -181
  113. package/src/components/resources/column-order.test.ts +104 -0
  114. package/src/components/resources/column-order.ts +64 -0
  115. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  116. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  117. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  118. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  119. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  120. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  121. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  122. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  123. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  124. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  126. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  127. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  128. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  129. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  130. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  131. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  132. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  133. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  134. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  135. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  136. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  137. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  138. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  139. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  140. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  141. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  142. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  143. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  144. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  145. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  146. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  147. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  148. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  149. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  150. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  151. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  152. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  153. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  154. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  155. package/src/components/resources/renderers/PodRenderer.tsx +81 -40
  156. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  157. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  158. package/src/components/resources/resource-utils-capi.ts +1 -1
  159. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  160. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  161. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  162. package/src/components/resources/resource-utils-contour.ts +16 -0
  163. package/src/components/resources/resource-utils-istio.ts +12 -8
  164. package/src/components/resources/resource-utils-knative.ts +18 -0
  165. package/src/components/resources/resource-utils.ts +96 -7
  166. package/src/components/resources/rules-column-width.test.ts +1 -1
  167. package/src/components/resources/summarize-scheduler-message.test.ts +31 -0
  168. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  169. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  170. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  171. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  172. package/src/components/shared/DetailShell.test.tsx +80 -0
  173. package/src/components/shared/DetailShell.tsx +85 -10
  174. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  175. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  176. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  177. package/src/components/shared/EditableYamlView.tsx +1 -1
  178. package/src/components/shared/ResourceActionsBar.tsx +14 -7
  179. package/src/components/shared/ResourceRendererDispatch.tsx +15 -10
  180. package/src/components/shared/index.ts +2 -0
  181. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  182. package/src/components/timeline/DiffViewer.tsx +113 -4
  183. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  184. package/src/components/timeline/shared.test.tsx +48 -0
  185. package/src/components/timeline/shared.tsx +14 -19
  186. package/src/components/timeline/timeline-filters.ts +1 -0
  187. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  188. package/src/components/topology/K8sResourceNode.tsx +44 -2
  189. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  190. package/src/components/topology/TopologyGraph.tsx +3 -71
  191. package/src/components/topology/TopologySearch.tsx +1 -0
  192. package/src/components/topology/internet-groups.test.ts +35 -0
  193. package/src/components/topology/internet-groups.ts +55 -0
  194. package/src/components/topology/layout.ts +1 -1
  195. package/src/components/ui/Badge.tsx +1 -0
  196. package/src/components/ui/FoldSection.tsx +68 -0
  197. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  198. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  199. package/src/components/ui/RefLink.tsx +20 -0
  200. package/src/components/ui/RowActionMenu.tsx +8 -3
  201. package/src/components/ui/Toast.tsx +15 -4
  202. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  203. package/src/components/ui/drawer-components.test.tsx +110 -1
  204. package/src/components/ui/drawer-components.tsx +62 -9
  205. package/src/components/ui/index.ts +4 -2
  206. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  207. package/src/components/ui/monacoRuntime.ts +14 -19
  208. package/src/components/ui/status-tone.test.ts +10 -1
  209. package/src/components/ui/status-tone.tsx +29 -0
  210. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  211. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  212. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  213. package/src/components/workload/WorkloadView.tsx +193 -71
  214. package/src/index.ts +14 -1
  215. package/src/monaco-deep.d.ts +19 -12
  216. package/src/theme/components.css +13 -0
  217. package/src/types/capacity.ts +11 -2
  218. package/src/types/core.ts +53 -2
  219. package/src/types/gitops-flux-status.test.ts +32 -1
  220. package/src/types/gitops-insights.ts +20 -2
  221. package/src/types/gitops-tree.ts +12 -0
  222. package/src/types/gitops.ts +26 -6
  223. package/src/utils/api-resources.ts +7 -0
  224. package/src/utils/application-history.ts +1 -0
  225. package/src/utils/cnpg-backup.test.ts +26 -0
  226. package/src/utils/cnpg-backup.ts +70 -0
  227. package/src/utils/format.test.ts +9 -0
  228. package/src/utils/format.ts +4 -0
  229. package/src/utils/gitops-owner.ts +3 -2
  230. package/src/utils/grant.test.ts +19 -0
  231. package/src/utils/grant.ts +35 -0
  232. package/src/utils/index.ts +4 -0
  233. package/src/utils/k8s-errors.ts +7 -7
  234. package/src/utils/log-format.test.ts +41 -0
  235. package/src/utils/log-format.ts +71 -15
  236. package/src/utils/log-level.ts +12 -8
  237. package/src/utils/navigation.test.ts +57 -0
  238. package/src/utils/navigation.ts +86 -8
  239. package/src/utils/object-reference-navigation.test.ts +43 -0
  240. package/src/utils/resource-hierarchy.test.ts +37 -0
  241. package/src/utils/resource-hierarchy.ts +15 -7
  242. package/src/utils/resource-icons.ts +2 -0
  243. package/src/utils/skeleton-yaml.test.ts +21 -0
  244. package/src/utils/skeleton-yaml.ts +16 -0
  245. package/src/utils/url-path.test.ts +35 -0
  246. package/src/utils/url-path.ts +21 -0
  247. package/src/utils/validators.test.ts +4 -13
  248. package/src/utils/validators.ts +5 -19
  249. package/src/utils/view-transition.test.ts +35 -1
  250. package/src/utils/view-transition.ts +16 -1
@@ -1,8 +1,8 @@
1
1
  import { useState, useMemo } from 'react'
2
2
  import { ChevronRight, ChevronDown, Filter } from 'lucide-react'
3
3
  import type { LogLevel } from './useLogBuffer'
4
- import { selectLevelField } from '../../utils/log-level'
5
- import { unescapeJsonStrings, parseLogfmt } from '../../utils/log-format'
4
+ import { selectLevelField, selectPostgresRecord } from '../../utils/log-level'
5
+ import { unescapeJsonStrings, parseLogfmt, tokenizeJson } from '../../utils/log-format'
6
6
  import { getLogPalette, getLogLevelColor, type LogPalette } from './log-palette'
7
7
 
8
8
  interface StructuredLogLineProps {
@@ -44,7 +44,7 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
44
44
 
45
45
  if (!parsed) {
46
46
  return (
47
- <span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${getLogLevelColor(level, isDark)}`}>
47
+ <span className={`${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'} ${getLogLevelColor(level, isDark)}`}>
48
48
  {content}
49
49
  </span>
50
50
  )
@@ -63,11 +63,11 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
63
63
  // Collapsed: entire summary line is clickable
64
64
  <span
65
65
  onClick={toggle}
66
- className={`cursor-pointer ${palette.hoverSurface} rounded px-0.5 -ml-0.5 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}
66
+ className={`cursor-pointer ${palette.hoverSurface} rounded px-0.5 -ml-0.5 ${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'}`}
67
67
  >
68
68
  <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
69
69
  <SummaryLine obj={parsed} level={level} palette={palette} />
70
- <span className={`${palette.textTertiary} ml-1`}>{`{${fieldCount} fields}`}</span>
70
+ <span className={`${palette.textTertiary} ml-1 inline-block whitespace-nowrap`}>{`{${fieldCount} fields}`}</span>
71
71
  </span>
72
72
  ) : (
73
73
  // Expanded: summary header is clickable to collapse, JSON content is selectable
@@ -78,9 +78,9 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
78
78
  >
79
79
  <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
80
80
  <SummaryLine obj={parsed} level={level} palette={palette} />
81
- <span className={`${palette.textTertiary} ml-1`}>{`{${fieldCount} fields}`}</span>
81
+ <span className={`${palette.textTertiary} ml-1 inline-block whitespace-nowrap`}>{`{${fieldCount} fields}`}</span>
82
82
  </span>
83
- <span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
83
+ <span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'}`}>
84
84
  {isLogfmt ? (
85
85
  <ExpandedLogfmt obj={parsed} onFilterValue={onFilterValue} palette={palette} />
86
86
  ) : (
@@ -113,7 +113,7 @@ function FilterableValue({
113
113
  <button
114
114
  type="button"
115
115
  onClick={(e) => { e.stopPropagation(); onFilter(value) }}
116
- className={`opacity-0 group-hover/flt:opacity-100 transition-opacity ${palette.textTertiary} ${palette.hoverText} px-0.5`}
116
+ className={`opacity-0 group-hover/flt:opacity-100 focus-visible:opacity-100 transition-opacity ${palette.textTertiary} ${palette.hoverText} px-0.5`}
117
117
  title={`Filter to lines containing "${value}"`}
118
118
  aria-label={`Filter to lines containing ${value}`}
119
119
  >
@@ -129,48 +129,40 @@ function FilterableValue({
129
129
  * and emits React nodes so the hover chip can be wired per value.
130
130
  */
131
131
  function JsonExpanded({ text, onFilterValue, palette }: { text: string; onFilterValue?: (v: string) => void; palette: LogPalette }) {
132
- const tokenRe = /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false)\b|\b(null)\b/g
133
132
  const nodes: React.ReactNode[] = []
134
- let lastIndex = 0
135
- let match: RegExpExecArray | null
136
133
  let idx = 0
137
- while ((match = tokenRe.exec(text)) !== null) {
138
- if (match.index > lastIndex) {
139
- nodes.push(<span key={`t${idx++}`}>{text.slice(lastIndex, match.index)}</span>)
140
- }
141
- const [, key, str, num, bool, nil] = match
142
- if (key !== undefined) {
143
- nodes.push(<span key={`k${idx++}`} style={{ color: palette.syntaxKey }}>{key}</span>)
134
+ for (const { type, value } of tokenizeJson(text)) {
135
+ if (type === 'key') {
136
+ nodes.push(<span key={`k${idx++}`} style={{ color: palette.syntaxKey }}>{value}</span>)
144
137
  nodes.push(<span key={`c${idx++}`}>:</span>)
145
- } else if (str !== undefined) {
138
+ } else if (type === 'string') {
146
139
  // Unescape the quoted string for the filter value (users expect to filter on
147
140
  // the displayed string, not JSON-escaped bytes).
148
- const inner = str.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
141
+ const inner = value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
149
142
  nodes.push(
150
143
  <FilterableValue key={`s${idx++}`} value={inner} onFilter={onFilterValue} color={palette.syntaxString} palette={palette} />
151
144
  )
152
- } else if (num !== undefined) {
145
+ } else if (type === 'number') {
153
146
  nodes.push(
154
- <FilterableValue key={`n${idx++}`} value={num} onFilter={onFilterValue} color={palette.syntaxNumber} palette={palette} />
147
+ <FilterableValue key={`n${idx++}`} value={value} onFilter={onFilterValue} color={palette.syntaxNumber} palette={palette} />
155
148
  )
156
- } else if (bool !== undefined) {
149
+ } else if (type === 'boolean') {
157
150
  nodes.push(
158
- <FilterableValue key={`b${idx++}`} value={bool} onFilter={onFilterValue} color={palette.syntaxBoolean} palette={palette} />
151
+ <FilterableValue key={`b${idx++}`} value={value} onFilter={onFilterValue} color={palette.syntaxBoolean} palette={palette} />
159
152
  )
160
- } else if (nil !== undefined) {
161
- nodes.push(<span key={`z${idx++}`} style={{ color: palette.syntaxNull }}>{nil}</span>)
153
+ } else if (type === 'null') {
154
+ nodes.push(<span key={`z${idx++}`} style={{ color: palette.syntaxNull }}>{value}</span>)
155
+ } else {
156
+ nodes.push(<span key={`t${idx++}`}>{value}</span>)
162
157
  }
163
- lastIndex = tokenRe.lastIndex
164
- }
165
- if (lastIndex < text.length) {
166
- nodes.push(<span key={`t${idx++}`}>{text.slice(lastIndex)}</span>)
167
158
  }
168
159
  return <>{nodes}</>
169
160
  }
170
161
 
171
162
  function SummaryLine({ obj, level, palette }: { obj: Record<string, unknown>; level: LogLevel; palette: LogPalette }) {
172
163
  const lvl = selectLevelField(obj)?.raw
173
- const msg = obj.msg ?? obj.message
164
+ const pgRecord = selectPostgresRecord(obj)
165
+ const msg = pgRecord?.record.message ?? obj.msg ?? obj.message
174
166
  const rawErr = obj.error ?? obj.err
175
167
  const err = typeof rawErr === 'string'
176
168
  ? rawErr
@@ -0,0 +1,65 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { expect, it, vi } from 'vitest'
5
+ import { WorkloadLogsViewer, type WorkloadLogsResult } from './WorkloadLogsViewer'
6
+ vi.mock('../ui/Toast', () => ({ useToast: () => ({ showError: vi.fn(), showSuccess: vi.fn() }) }))
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+ it('uses a host empty state and disables source controls until a source is read', async () => {
9
+ const host = document.createElement('div'); const root = createRoot(host)
10
+ let result: WorkloadLogsResult = { pods: [], logs: [], emptyMessage: 'No instance Pods yet' }
11
+ const stream = vi.fn()
12
+ const fetchAll = vi.fn(async () => result)
13
+ await act(async () => root.render(<WorkloadLogsViewer name="analytics" fetchAll={fetchAll} createStream={stream} disableSourceControlsWithoutSource emptySourceState={<><p>The first instance cannot start.</p><a href="?tab=overview">Overview’s problem</a></>} />))
14
+ expect(host.textContent).toContain('The first instance cannot start')
15
+ expect(host.querySelector('a')?.getAttribute('href')).toBe('?tab=overview')
16
+ expect(host.querySelector('fieldset')?.disabled).toBe(true)
17
+ expect([...host.querySelectorAll('select')].every((el) => el.matches(':disabled'))).toBe(true)
18
+ const streamButton = [...host.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === 'Stream')!
19
+ expect(streamButton.disabled).toBe(true)
20
+ act(() => streamButton.click())
21
+ expect(stream).not.toHaveBeenCalled()
22
+ result = { pods: [{ name: 'analytics-1', containers: ['postgres'], ready: true }], logs: [] }
23
+ await act(async () => [...host.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.querySelector('.lucide-rotate-ccw'))!.click())
24
+ expect(host.querySelector('fieldset')?.disabled).toBe(false)
25
+ expect(streamButton.disabled).toBe(false)
26
+ expect(host.textContent).not.toContain('The first instance cannot start')
27
+ await act(async () => root.unmount())
28
+ })
29
+
30
+ it('keeps streaming available for hosts that wait for Pods to appear', async () => {
31
+ const host = document.createElement('div'); const root = createRoot(host)
32
+ await act(async () => root.render(<WorkloadLogsViewer name="pending-job" fetchAll={async () => ({ pods: [], logs: [] })} createStream={vi.fn()} />))
33
+ expect([...host.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === 'Stream')?.disabled).toBe(false)
34
+ expect(host.querySelector('fieldset')?.disabled).toBe(false)
35
+ await act(async () => root.unmount())
36
+ })
37
+
38
+
39
+ it('keeps the all-container choice available by default', async () => {
40
+ const host = document.createElement('div'); const root = createRoot(host)
41
+ const fetchAll = vi.fn(async () => ({ pods: [{ name: 'analytics-1', containers: ['postgres', 'plugin-barman-cloud'], ready: true }], logs: [] }))
42
+ await act(async () => root.render(<WorkloadLogsViewer name="analytics" fetchAll={fetchAll} />))
43
+ const select = host.querySelector<HTMLSelectElement>('select')!
44
+ expect([...select.options].map((option) => option.value)).toEqual(['', 'postgres', 'plugin-barman-cloud'])
45
+ expect(select.options[0].text).toBe('All containers')
46
+ expect(fetchAll).toHaveBeenCalledWith(expect.objectContaining({ container: undefined }))
47
+ await act(async () => root.unmount())
48
+ })
49
+
50
+ it('offers only named containers when the backend cannot read all containers', async () => {
51
+ const host = document.createElement('div'); const root = createRoot(host)
52
+ const fetchAll = vi.fn(async (_params: unknown) => ({ pods: [{ name: 'analytics-1', containers: ['postgres', 'plugin-barman-cloud'], ready: true }], logs: [] }))
53
+ await act(async () => root.render(<WorkloadLogsViewer name="analytics" fetchAll={fetchAll} initialContainer="postgres" allowAllContainers={false} />))
54
+ const select = host.querySelector<HTMLSelectElement>('select')!
55
+ expect([...select.options].map((option) => option.value)).toEqual(['postgres', 'plugin-barman-cloud'])
56
+ expect(host.textContent).not.toContain('All containers')
57
+ expect(select.value).toBe('postgres')
58
+ expect(fetchAll).toHaveBeenCalledWith(expect.objectContaining({ container: 'postgres' }))
59
+ await act(async () => {
60
+ select.value = 'plugin-barman-cloud'
61
+ select.dispatchEvent(new Event('change', { bubbles: true }))
62
+ })
63
+ expect(fetchAll).toHaveBeenLastCalledWith(expect.objectContaining({ container: 'plugin-barman-cloud' }))
64
+ await act(async () => root.unmount())
65
+ })
@@ -1,5 +1,5 @@
1
- import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
2
- import { Filter, ChevronDown } from 'lucide-react'
1
+ import { useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from 'react'
2
+ import { Filter, ChevronDown, AlertTriangle } from 'lucide-react'
3
3
  import { parseLogRange } from '../../utils/log-format'
4
4
  import { triggerDownload } from '../../utils/download'
5
5
  import { useLogBuffer } from './useLogBuffer'
@@ -10,6 +10,7 @@ import type { LogExportPayload } from '../../utils/log-export'
10
10
  import type { LogPalette } from './log-palette'
11
11
  import type { WorkloadPodInfo } from '../../types'
12
12
  import { useToast } from '../ui/Toast'
13
+ import { toneTextClass } from '../ui/status-tone'
13
14
 
14
15
  export interface WorkloadRawLog {
15
16
  pod: string
@@ -38,6 +39,8 @@ export interface WorkloadLogsResult {
38
39
  }
39
40
 
40
41
  export interface WorkloadLogsViewerProps {
42
+ /** Disable streaming and the source filters while no log source exists. Off by default: streams can wait for Pods to appear. */
43
+ disableSourceControlsWithoutSource?: boolean
41
44
  /** Workload name — used for the download filename */
42
45
  name: string
43
46
  /**
@@ -65,14 +68,20 @@ export interface WorkloadLogsViewerProps {
65
68
  autoStream?: boolean
66
69
  /** Pods selected when the pod list first loads; all pods when empty or none match. */
67
70
  initialPods?: string[]
71
+ /** Container selected on mount; all containers when unset. */
72
+ initialContainer?: string
73
+ /** Whether the backend supports reading all containers. Defaults to true; set initialContainer when false. */
74
+ allowAllContainers?: boolean
75
+ /** Rendered only when the loaded source list is empty. */
76
+ emptySourceState?: ReactNode
68
77
  }
69
78
 
70
- export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, defaultDark, autoStream = false, initialPods }: WorkloadLogsViewerProps) {
79
+ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownload, forceDark, defaultDark, autoStream = false, initialPods, initialContainer, allowAllContainers = true, emptySourceState, disableSourceControlsWithoutSource = false }: WorkloadLogsViewerProps) {
71
80
  const initialSelection = (names: string[]) => {
72
81
  const wanted = names.filter((n) => initialPods?.includes(n))
73
82
  return new Set(wanted.length > 0 ? wanted : names)
74
83
  }
75
- const [selectedContainer, setSelectedContainer] = useState<string>('')
84
+ const [selectedContainer, setSelectedContainer] = useState<string>(initialContainer ?? '')
76
85
  const [pods, setPods] = useState<WorkloadPodInfo[]>([])
77
86
  const [selectedPods, setSelectedPods] = useState<Set<string>>(new Set())
78
87
  const [isLoading, setIsLoading] = useState(false)
@@ -88,10 +97,11 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
88
97
  const { showError, showSuccess } = useToast()
89
98
 
90
99
  const { tailLines, sinceSeconds } = parseLogRange(logRange)
91
- const { entries, append, set, clear } = useLogBuffer()
100
+ const { entries, isFollowing, pendingLineCount, retainedLineCount, missingLineCount, getRetainedEntries, setFollowing, append, set, clear } = useLogBuffer()
92
101
  const { isStreaming, streamError, connecting, startStreaming, stopStreaming } = useLogStream()
93
102
 
94
103
  const willAutoStream = autoStream && !!createStream
104
+ const sourceUnavailable = disableSourceControlsWithoutSource && pods.length === 0
95
105
  // null sentinel so the initial selectedContainer ('' = all) still arms once.
96
106
  const autoStartedForRef = useRef<string | null>(null)
97
107
  const userStoppedRef = useRef(false)
@@ -317,10 +327,11 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
317
327
  const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
318
328
  <>
319
329
  {/* Pod filter */}
330
+ <fieldset disabled={sourceUnavailable} className="flex items-center gap-2 disabled:opacity-50">
320
331
  <div className="relative">
321
332
  <button
322
333
  onClick={() => setShowPodFilter(v => !v)}
323
- className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors ${
334
+ className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded whitespace-nowrap transition-colors ${
324
335
  showPodFilter ? palette.toolbarActive : `${palette.elevatedBg} ${palette.textSecondary} ${palette.hoverBg}`
325
336
  }`}
326
337
  >
@@ -376,7 +387,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
376
387
  containers={allContainers}
377
388
  value={selectedContainer}
378
389
  onChange={setSelectedContainer}
379
- includeAll
390
+ includeAll={allowAllContainers}
380
391
  isDark={isDark}
381
392
  />
382
393
 
@@ -386,8 +397,10 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
386
397
  lineOptions={[50, 100, 500, 1000]}
387
398
  tooltip="How many logs to load per pod — by line count or time range"
388
399
  isDark={isDark}
389
- disabled={isStreaming}
400
+ disabled={isStreaming || sourceUnavailable}
401
+ disabledReason={sourceUnavailable ? 'No log source is available yet' : undefined}
390
402
  />
403
+ </fieldset>
391
404
  </>
392
405
  )
393
406
 
@@ -397,22 +410,44 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
397
410
 
398
411
  return (
399
412
  <div className="flex h-full min-h-0 flex-col">
400
- {notice && <div role="status" className="shrink-0 border-b border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">{notice}</div>}
401
- {capturedAt && <div className="shrink-0 border-b border-theme-border px-3 py-1 text-xs text-theme-text-secondary">Snapshot captured {new Date(capturedAt).toLocaleTimeString()}</div>}
413
+ {/* One status line. A notice says what the snapshot could not show, so it
414
+ stays marked; the capture time alone is quiet. */}
415
+ {(notice || capturedAt) && (
416
+ <div
417
+ role="status"
418
+ className={`flex shrink-0 flex-wrap items-baseline gap-x-2 border-b border-theme-border px-3 py-1 text-xs ${notice ? 'bg-theme-elevated text-theme-text-secondary' : 'text-theme-text-tertiary'}`}
419
+ >
420
+ {capturedAt && <span className="whitespace-nowrap">Snapshot {new Date(capturedAt).toLocaleTimeString()}</span>}
421
+ {capturedAt && notice && <span aria-hidden>·</span>}
422
+ {notice && (
423
+ <span className="inline-flex min-w-0 items-baseline gap-1.5">
424
+ <AlertTriangle className={`h-3 w-3 shrink-0 self-center ${toneTextClass('degraded')}`} aria-hidden />
425
+ <span>{notice}</span>
426
+ </span>
427
+ )}
428
+ </div>
429
+ )}
402
430
  {(fetchError || streamError) && entries.length > 0 && <div role="alert" className="shrink-0 border-b border-theme-border bg-theme-surface px-3 py-2 text-xs text-theme-text-secondary">{fetchError || streamError} · Previously loaded logs remain below.</div>}
403
431
  <div className="min-h-0 flex-1"><LogCore
404
432
  entries={filteredEntries}
433
+ isFollowing={isFollowing}
434
+ onFollowingChange={setFollowing}
435
+ pendingLineCount={pendingLineCount}
436
+ retainedLineCount={retainedLineCount}
437
+ missingLineCount={missingLineCount}
438
+ getRetainedEntries={getRetainedEntries}
405
439
  allEntries={entries}
406
440
  isLoading={isLoading || isConnecting}
407
441
  isStreaming={isStreaming}
408
442
  onStartStream={createStream ? handleStartStreaming : undefined}
443
+ sourceUnavailable={sourceUnavailable}
409
444
  onStopStream={handleStopStreaming}
410
445
  onRefresh={loadLogs}
411
446
  onDownload={downloadLogs}
412
447
  onClear={clear}
413
448
  toolbarExtra={renderToolbarExtra}
414
449
  showPodName
415
- emptyMessage={emptyMessage || (pods.length === 0 ? 'No pods found' : 'No logs available')}
450
+ emptyMessage={pods.length === 0 && emptySourceState ? emptySourceState : emptyMessage || (pods.length === 0 ? 'No pods found' : 'No logs available')}
416
451
  emptyCommand={emptyCommand}
417
452
  errorMessage={entries.length === 0 ? fetchError || streamError : null}
418
453
  forceDark={forceDark}
@@ -0,0 +1,149 @@
1
+ // @vitest-environment jsdom
2
+ import { act, StrictMode } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { useLogBuffer } from './useLogBuffer'
6
+
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+
9
+ let root: Root
10
+ let element: HTMLDivElement
11
+ let current: ReturnType<typeof useLogBuffer> | undefined
12
+ let flush: FrameRequestCallback | undefined
13
+ const buffer = () => {
14
+ if (!current) throw new Error('Buffer not mounted')
15
+ return current
16
+ }
17
+ function Harness() {
18
+ current = useLogBuffer()
19
+ return null
20
+ }
21
+ const lines = (count: number) => Array.from({ length: count }, (_, i) => ({ content: `INFO line ${i}`, container: 'app', timestamp: '' }))
22
+ async function append(count: number) {
23
+ await act(async () => {
24
+ buffer().appendBatch(lines(count))
25
+ flush?.(0)
26
+ })
27
+ }
28
+
29
+ beforeEach(async () => {
30
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { flush = callback; return 1 })
31
+ vi.stubGlobal('cancelAnimationFrame', () => { flush = undefined })
32
+ element = document.createElement('div')
33
+ root = createRoot(element)
34
+ await act(async () => root.render(<StrictMode><Harness /></StrictMode>))
35
+ })
36
+ afterEach(async () => {
37
+ await act(async () => root.unmount())
38
+ current = undefined
39
+ flush = undefined
40
+ vi.unstubAllGlobals()
41
+ })
42
+
43
+ describe('reading a live log buffer', () => {
44
+ it('appends below the reader without replacing existing records before rollover', async () => {
45
+ await append(100)
46
+ const anchor = buffer().entries[50]
47
+ await act(async () => buffer().setFollowing(false))
48
+ await append(100)
49
+ expect(buffer().entries).toHaveLength(200)
50
+ expect(buffer().entries[50]).toBe(anchor)
51
+ expect(buffer().isFollowing).toBe(false)
52
+ expect(buffer().pendingLineCount).toBe(0)
53
+ })
54
+
55
+ it('keeps the reading window through repeated eviction and resumes at the newest records', async () => {
56
+ await append(10_000)
57
+ await act(async () => buffer().setFollowing(false))
58
+ const reading = buffer().entries
59
+ await append(500)
60
+ await append(20_000)
61
+ expect(buffer().entries).toBe(reading)
62
+ expect(buffer().entries[50]).toBe(reading[50])
63
+ expect(buffer().pendingLineCount).toBe(20_500)
64
+ expect(buffer().isFollowing).toBe(false)
65
+ await act(async () => buffer().setFollowing(true))
66
+ expect(buffer().entries).toHaveLength(10_000)
67
+ expect(buffer().entries.at(-1)?.id).toBe(30_499)
68
+ expect(buffer().pendingLineCount).toBe(0)
69
+ expect(buffer().isFollowing).toBe(true)
70
+ })
71
+
72
+ it('still bounds the buffer while following', async () => {
73
+ await append(10_500)
74
+ expect(buffer().entries).toHaveLength(10_000)
75
+ expect(buffer().entries[0].id).toBe(500)
76
+ expect(buffer().isFollowing).toBe(true)
77
+ })
78
+
79
+ it('releases the saved history on refresh and clear', async () => {
80
+ await append(10_000)
81
+ await act(async () => buffer().setFollowing(false))
82
+ await append(100)
83
+ await act(async () => buffer().set(lines(5)))
84
+ expect(buffer().entries).toHaveLength(5)
85
+ expect(buffer().isFollowing).toBe(true)
86
+ await act(async () => buffer().setFollowing(false))
87
+ await act(async () => buffer().clear())
88
+ expect(buffer().entries).toHaveLength(0)
89
+ expect(buffer().pendingLineCount).toBe(0)
90
+ expect(buffer().isFollowing).toBe(true)
91
+ })
92
+ })
93
+
94
+ it('exports overlapping windows once and reports expired history after they separate', async () => {
95
+ await append(10_000)
96
+ await act(async () => buffer().setFollowing(false))
97
+ await append(500)
98
+ expect(buffer().retainedLineCount).toBe(10_500)
99
+ expect(buffer().missingLineCount).toBe(0)
100
+ expect(buffer().getRetainedEntries().map(entry => entry.id)).toEqual(Array.from({ length: 10_500 }, (_, i) => i))
101
+ await append(10_000)
102
+ const retained = buffer().getRetainedEntries()
103
+ expect(buffer().retainedLineCount).toBe(20_000)
104
+ expect(buffer().missingLineCount).toBe(500)
105
+ expect(retained).toHaveLength(20_000)
106
+ expect(retained[9_999].id).toBe(9_999)
107
+ expect(retained[10_000].id).toBe(10_500)
108
+ expect(retained.at(-1)?.id).toBe(20_499)
109
+ await act(async () => buffer().setFollowing(true))
110
+ expect(buffer().retainedLineCount).toBe(10_000)
111
+ expect(buffer().missingLineCount).toBe(0)
112
+ await act(async () => buffer().clear())
113
+ expect(buffer().retainedLineCount).toBe(0)
114
+ expect(buffer().getRetainedEntries()).toEqual([])
115
+ })
116
+
117
+ it('keeps collecting the latest logs when animation frames stop running', async () => {
118
+ await act(async () => {
119
+ buffer().appendBatch(lines(10_000))
120
+ })
121
+ // No animation frame was delivered, as happens in a hidden tab.
122
+ expect(buffer().entries).toHaveLength(10_000)
123
+ await act(async () => buffer().setFollowing(false))
124
+ const saved = buffer().entries
125
+ await act(async () => buffer().appendBatch(lines(30_000)))
126
+ expect(buffer().entries).toBe(saved)
127
+ expect(buffer().pendingLineCount).toBe(30_000)
128
+ expect(buffer().getRetainedEntries().at(-1)?.id).toBe(39_999)
129
+ expect(buffer().retainedLineCount).toBe(20_000)
130
+ expect(buffer().missingLineCount).toBe(20_000)
131
+ await act(async () => buffer().appendBatch(lines(5)))
132
+ expect(buffer().pendingLineCount).toBe(30_000)
133
+ await act(async () => flush?.(0))
134
+ expect(buffer().pendingLineCount).toBe(30_005)
135
+ expect(buffer().getRetainedEntries().at(-1)?.id).toBe(40_004)
136
+ expect(buffer().missingLineCount).toBe(20_005)
137
+ })
138
+
139
+ it('does not count records from before an empty reset when pausing and receiving a large batch', async () => {
140
+ await append(25_000)
141
+ for (const reset of [() => buffer().clear(), () => buffer().set([])]) {
142
+ await act(async () => { reset(); buffer().setFollowing(false) })
143
+ await append(20_001)
144
+ expect(buffer().entries).toHaveLength(10_000)
145
+ expect(buffer().pendingLineCount).toBe(10_001)
146
+ expect(buffer().missingLineCount).toBe(1)
147
+ expect(buffer().retainedLineCount).toBe(20_000)
148
+ }
149
+ })
@@ -14,4 +14,12 @@ describe('detectLogLevel with CloudNativePG records', () => {
14
14
  expect(detectLogLevel(JSON.stringify({ level: 'error', msg: 'request failed', record: { error_severity: 20 } }))).toBe('error')
15
15
  expect(detectLogLevel(JSON.stringify({ level: 'error', msg: 'x', record: { error_severity: 'minor' } }))).toBe('error')
16
16
  })
17
+
18
+ it.each([
19
+ { msg: 'record' },
20
+ { logger: 'worker', msg: 'record' },
21
+ { logger: 'postgres', msg: 'wrapper message' },
22
+ ])('does not override an unrelated workload severity: %j', (fields) => {
23
+ expect(detectLogLevel(JSON.stringify({ level: 'info', ...fields, record: { error_severity: 'FATAL', message: 'unrelated nested text' } }))).toBe('info')
24
+ })
17
25
  })
@@ -35,6 +35,12 @@ type RawLogEntry = Omit<LogEntry, 'id' | 'level' | 'levelSource' | 'isJson' | 'i
35
35
 
36
36
  interface UseLogBufferReturn {
37
37
  entries: LogEntry[]
38
+ isFollowing: boolean
39
+ retainedLineCount: number
40
+ missingLineCount: number
41
+ getRetainedEntries: () => LogEntry[]
42
+ pendingLineCount: number
43
+ setFollowing: (following: boolean) => void
38
44
  append: (entry: RawLogEntry) => void
39
45
  appendBatch: (entries: RawLogEntry[]) => void
40
46
  set: (entries: RawLogEntry[]) => void
@@ -42,7 +48,7 @@ interface UseLogBufferReturn {
42
48
  }
43
49
 
44
50
  export function useLogBuffer(): UseLogBufferReturn {
45
- const [entries, setEntries] = useState<LogEntry[]>([])
51
+ const [buffer, setBuffer] = useState<{ latest: LogEntry[]; reading: LogEntry[] | null }>({ latest: [], reading: null })
46
52
  const idCounter = useRef(0)
47
53
  const pendingRef = useRef<RawLogEntry[]>([])
48
54
  const rafRef = useRef<number | null>(null)
@@ -65,30 +71,35 @@ export function useLogBuffer(): UseLogBufferReturn {
65
71
  const batch = pendingRef.current
66
72
  if (batch.length === 0) return
67
73
  pendingRef.current = []
68
-
69
- setEntries(prev => {
70
- const enriched = batch.map(enrichEntry)
71
- const combined = [...prev, ...enriched]
72
- if (combined.length > MAX_BUFFER_SIZE) {
73
- return combined.slice(combined.length - MAX_BUFFER_SIZE)
74
- }
75
- return combined
74
+ const enriched = batch.map(enrichEntry)
75
+
76
+ setBuffer(prev => {
77
+ const combined = [...prev.latest, ...enriched]
78
+ const latest = combined.length > MAX_BUFFER_SIZE ? combined.slice(-MAX_BUFFER_SIZE) : combined
79
+ // Keep appending while the reader's window fits. Once eviction begins,
80
+ // retain that window rather than replacing the lines being read.
81
+ const reading = prev.reading === null ? null
82
+ : prev.reading === prev.latest && combined.length <= MAX_BUFFER_SIZE ? latest : prev.reading
83
+ return { latest, reading }
76
84
  })
77
85
  }, [enrichEntry])
78
86
 
79
87
  const append = useCallback((entry: RawLogEntry) => {
80
88
  pendingRef.current.push(entry)
89
+ // Hidden tabs can stop delivering animation frames while logs still arrive.
90
+ if (pendingRef.current.length >= MAX_BUFFER_SIZE) {
91
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
92
+ flushPending()
93
+ return
94
+ }
81
95
  if (rafRef.current === null) {
82
96
  rafRef.current = requestAnimationFrame(flushPending)
83
97
  }
84
98
  }, [flushPending])
85
99
 
86
100
  const appendBatch = useCallback((batch: RawLogEntry[]) => {
87
- pendingRef.current.push(...batch)
88
- if (rafRef.current === null) {
89
- rafRef.current = requestAnimationFrame(flushPending)
90
- }
91
- }, [flushPending])
101
+ for (const entry of batch) append(entry)
102
+ }, [append])
92
103
 
93
104
  const set = useCallback((rawEntries: RawLogEntry[]) => {
94
105
  // Cancel any pending RAF
@@ -100,9 +111,9 @@ export function useLogBuffer(): UseLogBufferReturn {
100
111
 
101
112
  const enriched = rawEntries.map(enrichEntry)
102
113
  if (enriched.length > MAX_BUFFER_SIZE) {
103
- setEntries(enriched.slice(enriched.length - MAX_BUFFER_SIZE))
114
+ setBuffer({ latest: enriched.slice(enriched.length - MAX_BUFFER_SIZE), reading: null })
104
115
  } else {
105
- setEntries(enriched)
116
+ setBuffer({ latest: enriched, reading: null })
106
117
  }
107
118
  }, [enrichEntry])
108
119
 
@@ -112,8 +123,27 @@ export function useLogBuffer(): UseLogBufferReturn {
112
123
  rafRef.current = null
113
124
  }
114
125
  pendingRef.current = []
115
- setEntries([])
126
+ setBuffer({ latest: [], reading: null })
127
+ }, [])
128
+
129
+ const setFollowing = useCallback((following: boolean) => {
130
+ setBuffer(prev => {
131
+ if (following) return prev.reading === null ? prev : { ...prev, reading: null }
132
+ return prev.reading === null ? { ...prev, reading: prev.latest } : prev
133
+ })
116
134
  }, [])
117
135
 
118
- return { entries, append, appendBatch, set, clear }
136
+ const entries = buffer.reading ?? buffer.latest
137
+ const pendingLineCount = (buffer.latest.at(-1)?.id ?? -1) - (entries.at(-1)?.id ?? -1)
138
+ const readingLastId = entries.at(-1)?.id ?? -1
139
+ const latestFirstId = buffer.latest[0]?.id ?? 0
140
+ const overlap = Math.max(0, readingLastId - latestFirstId + 1)
141
+ const retainedLineCount = buffer.reading === null ? buffer.latest.length : entries.length + buffer.latest.length - overlap
142
+ const missingLineCount = buffer.reading === null ? 0 : Math.max(0, latestFirstId - readingLastId - 1)
143
+ const getRetainedEntries = useCallback(() => {
144
+ if (buffer.reading === null || buffer.reading === buffer.latest) return buffer.latest
145
+ const lastId = buffer.reading.at(-1)?.id ?? -1
146
+ return [...buffer.reading, ...buffer.latest.filter(entry => entry.id > lastId)]
147
+ }, [buffer])
148
+ return { entries, retainedLineCount, missingLineCount, getRetainedEntries, isFollowing: buffer.reading === null, pendingLineCount, setFollowing, append, appendBatch, set, clear }
119
149
  }
@@ -54,6 +54,7 @@ export function useLogSearch(
54
54
  virtuosoRef: React.RefObject<VirtuosoHandle | null>,
55
55
  /** Id of the record an entry belongs to (its first line's id); defaults to the entry itself */
56
56
  recordIdOf?: (entry: LogEntry) => number,
57
+ onNavigate?: () => void,
57
58
  ): UseLogSearchReturn {
58
59
  const [query, setQuery] = useState('')
59
60
  const [isRegex, setIsRegex] = useState(false)
@@ -122,6 +123,7 @@ export function useLogSearch(
122
123
  const scrollToMatch = useCallback((matchIdx: number) => {
123
124
  if (matchIdx < 0 || matchIdx >= matchIndices.length) return
124
125
  if (mode === 'hide') return
126
+ onNavigate?.()
125
127
  if (mode === 'only') {
126
128
  // The list holds whole matching records, so find the matched line within it
127
129
  const index = filteredIndexById?.get(entries[matchIndices[matchIdx]].id)
@@ -139,7 +141,7 @@ export function useLogSearch(
139
141
  behavior: 'smooth',
140
142
  })
141
143
  }
142
- }, [entries, matchIndices, mode, filteredIndexById, virtuosoRef])
144
+ }, [entries, matchIndices, mode, filteredIndexById, virtuosoRef, onNavigate])
143
145
 
144
146
  const goToNext = useCallback(() => {
145
147
  if (matchIndices.length === 0 || mode === 'hide') return