@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.
- package/README.md +41 -0
- package/package.json +6 -6
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +7 -3
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +142 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -81
- package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
- package/src/components/gitops/insights/insights-helpers.ts +7 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +91 -2
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +76 -76
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +23 -0
- package/src/components/gitops/tree/tree-helpers.ts +12 -2
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +44 -6
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +8 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +12 -1
- package/src/components/issues/issues.test.ts +75 -1
- package/src/components/issues/severity.ts +30 -2
- package/src/components/issues/types.ts +55 -4
- package/src/components/logs/LogCore.theme.test.tsx +206 -3
- package/src/components/logs/LogCore.tsx +180 -53
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +369 -0
- package/src/components/logs/LogsViewer.tsx +91 -13
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +23 -31
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +46 -11
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/logs/useLogStream.ts +17 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +296 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +392 -181
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/HPARenderer.tsx +56 -16
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -40
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -20
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils.ts +96 -7
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +31 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +14 -7
- package/src/components/shared/ResourceRendererDispatch.tsx +15 -10
- package/src/components/shared/index.ts +2 -0
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +3 -71
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/Toast.tsx +15 -4
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +110 -1
- package/src/components/ui/drawer-components.tsx +62 -9
- package/src/components/ui/index.ts +4 -2
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +193 -71
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +11 -2
- package/src/types/core.ts +53 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +20 -2
- package/src/types/gitops-tree.ts +12 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +9 -0
- package/src/utils/format.ts +4 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +57 -0
- package/src/utils/navigation.ts +86 -8
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +37 -0
- package/src/utils/resource-hierarchy.ts +15 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
138
|
-
if (
|
|
139
|
-
nodes.push(<span key={`
|
|
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 (
|
|
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 =
|
|
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 (
|
|
145
|
+
} else if (type === 'number') {
|
|
153
146
|
nodes.push(
|
|
154
|
-
<FilterableValue key={`n${idx++}`} value={
|
|
147
|
+
<FilterableValue key={`n${idx++}`} value={value} onFilter={onFilterValue} color={palette.syntaxNumber} palette={palette} />
|
|
155
148
|
)
|
|
156
|
-
} else if (
|
|
149
|
+
} else if (type === 'boolean') {
|
|
157
150
|
nodes.push(
|
|
158
|
-
<FilterableValue key={`b${idx++}`} value={
|
|
151
|
+
<FilterableValue key={`b${idx++}`} value={value} onFilter={onFilterValue} color={palette.syntaxBoolean} palette={palette} />
|
|
159
152
|
)
|
|
160
|
-
} else if (
|
|
161
|
-
nodes.push(<span key={`z${idx++}`} style={{ color: palette.syntaxNull }}>{
|
|
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
|
|
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
|
-
{
|
|
401
|
-
|
|
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 [
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
const combined = [...prev, ...enriched]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
114
|
+
setBuffer({ latest: enriched.slice(enriched.length - MAX_BUFFER_SIZE), reading: null })
|
|
104
115
|
} else {
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|