@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
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ToastProvider } from '../ui/Toast'
|
|
6
|
+
import { LogsViewer, type LogsFetchParams } from './LogsViewer'
|
|
7
|
+
|
|
8
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
9
|
+
|
|
10
|
+
class FakeEventSource extends EventTarget {
|
|
11
|
+
static last: FakeEventSource | undefined
|
|
12
|
+
closed = false
|
|
13
|
+
constructor(readonly url: string) {
|
|
14
|
+
super()
|
|
15
|
+
FakeEventSource.last = this
|
|
16
|
+
}
|
|
17
|
+
close() { this.closed = true }
|
|
18
|
+
emit(type: string, data: unknown) {
|
|
19
|
+
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) }))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The shape the app's API client throws for a JSON error body.
|
|
24
|
+
class ApiError extends Error {
|
|
25
|
+
constructor(message: string, readonly data: Record<string, unknown>) {
|
|
26
|
+
super(message)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const NO_EARLIER_RUN = 'This container has not restarted, so there is no earlier run to show.'
|
|
31
|
+
|
|
32
|
+
it('keeps received logs visible and reports a node failure until streaming is restarted', async () => {
|
|
33
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
34
|
+
await renderViewer(fetchLogs, true)
|
|
35
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
36
|
+
await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-20T10:00:00Z', content: 'before failure', container: 'app' }))
|
|
37
|
+
// The buffer batches streamed records before rendering them.
|
|
38
|
+
await act(async () => { await new Promise(resolve => setTimeout(resolve, 100)) })
|
|
39
|
+
await act(async () => FakeEventSource.last?.emit('error', { error: 'Kubernetes did not return this container\'s logs', error_code: 'logs_unavailable' }))
|
|
40
|
+
expect(element.querySelector('[role="alert"]')?.textContent).toContain('Kubernetes did not return')
|
|
41
|
+
expect(element.textContent).toContain('Previously loaded logs remain below.')
|
|
42
|
+
expect(element.textContent).toContain('OTHER 1')
|
|
43
|
+
expect(FakeEventSource.last?.closed).toBe(true)
|
|
44
|
+
await act(async () => button('Stream').click())
|
|
45
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
46
|
+
expect(element.querySelector('[role="alert"]')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
let root: Root
|
|
50
|
+
let element: HTMLDivElement
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.stubGlobal('EventSource', FakeEventSource)
|
|
53
|
+
element = document.createElement('div')
|
|
54
|
+
document.body.appendChild(element)
|
|
55
|
+
root = createRoot(element)
|
|
56
|
+
})
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
await act(async () => root.unmount())
|
|
59
|
+
element.remove()
|
|
60
|
+
vi.unstubAllGlobals()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
async function renderViewer(fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>, autoStream = false, containers = ['app']) {
|
|
64
|
+
await act(async () =>
|
|
65
|
+
root.render(
|
|
66
|
+
<ToastProvider>
|
|
67
|
+
<LogsViewer
|
|
68
|
+
namespace="default"
|
|
69
|
+
podName="web-0"
|
|
70
|
+
containers={containers}
|
|
71
|
+
fetchLogs={fetchLogs}
|
|
72
|
+
createStream={() => new EventSource('/stream')}
|
|
73
|
+
autoStream={autoStream}
|
|
74
|
+
/>
|
|
75
|
+
</ToastProvider>,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function previousCheckbox(): HTMLInputElement {
|
|
81
|
+
const box = element.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
|
82
|
+
if (!box) throw new Error('Previous checkbox not rendered')
|
|
83
|
+
return box
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function refreshButton(): HTMLButtonElement {
|
|
87
|
+
const found = element.querySelector<HTMLButtonElement>('button[aria-label="Refresh logs"]')
|
|
88
|
+
if (!found) throw new Error('Refresh button not rendered')
|
|
89
|
+
return found
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
it('keeps records after a failed Refresh of the same selection and replaces them on success', async () => {
|
|
93
|
+
const fetchLogs = vi.fn(async () => ({ app: '2026-10-20T10:00:00Z retained record' }))
|
|
94
|
+
await renderViewer(fetchLogs)
|
|
95
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
96
|
+
fetchLogs.mockRejectedValueOnce(new Error('snapshot failed'))
|
|
97
|
+
await act(async () => refreshButton().click())
|
|
98
|
+
expect(element.querySelector('[role="alert"]')?.textContent).toContain('snapshot failed')
|
|
99
|
+
expect(element.textContent).toContain('OTHER 1')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fetchLogs.mockResolvedValueOnce({ app: '' })
|
|
103
|
+
await act(async () => refreshButton().click())
|
|
104
|
+
expect(element.querySelector('[role="alert"]')).toBeNull()
|
|
105
|
+
expect(element.textContent).not.toContain('OTHER 1')
|
|
106
|
+
expect(element.textContent).not.toContain('snapshot failed')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('keeps streamed records when Refresh fails after the stream stops', async () => {
|
|
110
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
111
|
+
await renderViewer(fetchLogs, true)
|
|
112
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
113
|
+
await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-20T10:00:00Z', content: 'retained record', container: 'app' }))
|
|
114
|
+
await act(async () => { await new Promise(resolve => setTimeout(resolve, 100)) })
|
|
115
|
+
await act(async () => button('Stop').click())
|
|
116
|
+
fetchLogs.mockRejectedValueOnce(new Error('snapshot failed'))
|
|
117
|
+
|
|
118
|
+
await act(async () => refreshButton().click())
|
|
119
|
+
|
|
120
|
+
expect(element.querySelector('[role="alert"]')?.textContent).toContain('snapshot failed')
|
|
121
|
+
expect(element.textContent).toContain('OTHER 1')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it.each(['container', 'previous', 'range'])('clears prior records when a new %s snapshot fails', async selection => {
|
|
125
|
+
const fetchLogs = vi.fn(async () => ({ app: '2026-10-20T10:00:00Z old selection' }))
|
|
126
|
+
await renderViewer(fetchLogs, false, ['app', 'sidecar'])
|
|
127
|
+
expect(element.textContent).toContain('OTHER 1')
|
|
128
|
+
fetchLogs.mockRejectedValueOnce(new Error('snapshot failed'))
|
|
129
|
+
|
|
130
|
+
await act(async () => {
|
|
131
|
+
if (selection === 'previous') {
|
|
132
|
+
previousCheckbox().click()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const select = element.querySelectorAll('select')[selection === 'container' ? 0 : 1]
|
|
136
|
+
if (!select) throw new Error('selection control not rendered')
|
|
137
|
+
select.value = selection === 'container' ? 'sidecar' : '1000'
|
|
138
|
+
select.dispatchEvent(new Event('change', { bubbles: true }))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
expect(element.textContent).toContain('snapshot failed')
|
|
142
|
+
expect(element.textContent).not.toContain('OTHER 1')
|
|
143
|
+
expect(element.textContent).not.toContain('Previously loaded logs remain below.')
|
|
144
|
+
expect(messageClass('snapshot failed')).toMatch(/text-red/)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
function button(label: string): HTMLButtonElement {
|
|
148
|
+
const found = Array.from(element.querySelectorAll('button')).find(b => b.textContent?.trim() === label)
|
|
149
|
+
if (!found) throw new Error(`${label} button not rendered`)
|
|
150
|
+
return found
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The class of the box the message sits in: the innermost element holding the
|
|
154
|
+
// text is the message itself, and its parent carries the colour.
|
|
155
|
+
function messageClass(text: string): string {
|
|
156
|
+
const message = Array.from(element.querySelectorAll('span, div')).filter(el => el.textContent === text).pop()
|
|
157
|
+
if (!message?.parentElement) throw new Error(`"${text}" not rendered`)
|
|
158
|
+
return message.parentElement.className
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
it('starting a stream leaves the Previous view and its answer behind', async () => {
|
|
162
|
+
const fetchLogs = vi.fn(async (params: LogsFetchParams) => {
|
|
163
|
+
if (params.previous) throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })
|
|
164
|
+
return { app: '' }
|
|
165
|
+
})
|
|
166
|
+
await renderViewer(fetchLogs)
|
|
167
|
+
await act(async () => previousCheckbox().click())
|
|
168
|
+
expect(element.textContent).toContain(NO_EARLIER_RUN)
|
|
169
|
+
const callsBeforeStream = fetchLogs.mock.calls.length
|
|
170
|
+
|
|
171
|
+
await act(async () => button('Stream').click())
|
|
172
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
173
|
+
|
|
174
|
+
expect(element.textContent).not.toContain(NO_EARLIER_RUN)
|
|
175
|
+
expect(previousCheckbox().checked).toBe(false)
|
|
176
|
+
// Unticking Previous must not also load a snapshot over the stream.
|
|
177
|
+
expect(fetchLogs.mock.calls.length).toBe(callsBeforeStream)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('with auto-stream on, Stop, Previous, then Stream returns to the live stream', async () => {
|
|
181
|
+
const fetchLogs = vi.fn(async (params: LogsFetchParams) => {
|
|
182
|
+
if (params.previous) throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })
|
|
183
|
+
return { app: '' }
|
|
184
|
+
})
|
|
185
|
+
await renderViewer(fetchLogs, true)
|
|
186
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
187
|
+
await act(async () => button('Stop').click())
|
|
188
|
+
await act(async () => previousCheckbox().click())
|
|
189
|
+
expect(element.textContent).toContain(NO_EARLIER_RUN)
|
|
190
|
+
const callsBeforeStream = fetchLogs.mock.calls.length
|
|
191
|
+
|
|
192
|
+
await act(async () => button('Stream').click())
|
|
193
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
194
|
+
|
|
195
|
+
expect(element.textContent).not.toContain(NO_EARLIER_RUN)
|
|
196
|
+
expect(previousCheckbox().checked).toBe(false)
|
|
197
|
+
expect(button('Stop')).toBeTruthy()
|
|
198
|
+
expect(fetchLogs.mock.calls.length).toBe(callsBeforeStream)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('with auto-stream on, Previous loads the earlier run once the stream ended by itself', async () => {
|
|
202
|
+
const fetchLogs = vi.fn(async (params: LogsFetchParams) =>
|
|
203
|
+
({ app: params.previous ? '2026-10-13T10:00:00Z earlier run crashed' : '' }))
|
|
204
|
+
await renderViewer(fetchLogs, true)
|
|
205
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
206
|
+
// The container exited: the stream ends without the user pressing Stop.
|
|
207
|
+
await act(async () => FakeEventSource.last?.emit('end', {}))
|
|
208
|
+
expect(button('Stream')).toBeTruthy()
|
|
209
|
+
|
|
210
|
+
await act(async () => previousCheckbox().click())
|
|
211
|
+
|
|
212
|
+
expect(fetchLogs).toHaveBeenCalledWith(expect.objectContaining({ previous: true }))
|
|
213
|
+
// The earlier run's line is in the buffer: the level chips count it.
|
|
214
|
+
expect(element.textContent).toContain('OTHER 1')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('with auto-stream on, a time range picked after the stream ended loads it instead of reopening the stream', async () => {
|
|
218
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
219
|
+
await renderViewer(fetchLogs, true)
|
|
220
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
221
|
+
await act(async () => FakeEventSource.last?.emit('end', {}))
|
|
222
|
+
const ended = FakeEventSource.last
|
|
223
|
+
await act(async () => previousCheckbox().click())
|
|
224
|
+
|
|
225
|
+
const range = element.querySelector<HTMLSelectElement>('select')
|
|
226
|
+
if (!range) throw new Error('range select not rendered')
|
|
227
|
+
await act(async () => {
|
|
228
|
+
range.value = range.options[range.options.length - 1].value
|
|
229
|
+
range.dispatchEvent(new Event('change', { bubbles: true }))
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
expect(FakeEventSource.last).toBe(ended)
|
|
233
|
+
expect(previousCheckbox().checked).toBe(true)
|
|
234
|
+
expect(fetchLogs).toHaveBeenLastCalledWith(expect.objectContaining({ previous: true, sinceSeconds: expect.any(Number) }))
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('with auto-stream on, a range picked while the stream opens reopens it with that range', async () => {
|
|
238
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
239
|
+
await renderViewer(fetchLogs, true)
|
|
240
|
+
const opening = FakeEventSource.last
|
|
241
|
+
|
|
242
|
+
const range = element.querySelector<HTMLSelectElement>('select')
|
|
243
|
+
if (!range) throw new Error('range select not rendered')
|
|
244
|
+
await act(async () => {
|
|
245
|
+
range.value = range.options[range.options.length - 1].value
|
|
246
|
+
range.dispatchEvent(new Event('change', { bubbles: true }))
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
expect(opening?.closed).toBe(true)
|
|
250
|
+
expect(FakeEventSource.last).not.toBe(opening)
|
|
251
|
+
expect(fetchLogs).not.toHaveBeenCalled()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('with auto-stream on, after the stream ended and was started again, a range picked while it opens reopens it', async () => {
|
|
255
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
256
|
+
await renderViewer(fetchLogs, true)
|
|
257
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
258
|
+
await act(async () => FakeEventSource.last?.emit('end', {}))
|
|
259
|
+
const range = element.querySelector<HTMLSelectElement>('select')
|
|
260
|
+
if (!range) throw new Error('range select not rendered')
|
|
261
|
+
const pick = (index: number) => act(async () => {
|
|
262
|
+
range.value = range.options[index].value
|
|
263
|
+
range.dispatchEvent(new Event('change', { bubbles: true }))
|
|
264
|
+
})
|
|
265
|
+
await pick(range.options.length - 1)
|
|
266
|
+
await act(async () => button('Stream').click())
|
|
267
|
+
const opening = FakeEventSource.last
|
|
268
|
+
|
|
269
|
+
await pick(range.options.length - 2)
|
|
270
|
+
|
|
271
|
+
expect(opening?.closed).toBe(true)
|
|
272
|
+
expect(FakeEventSource.last).not.toBe(opening)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('a range picked while a manually started stream opens does not cancel it', async () => {
|
|
276
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
277
|
+
await renderViewer(fetchLogs, true)
|
|
278
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
279
|
+
await act(async () => button('Stop').click())
|
|
280
|
+
await act(async () => button('Stream').click())
|
|
281
|
+
const opening = FakeEventSource.last
|
|
282
|
+
const range = element.querySelector<HTMLSelectElement>('select')
|
|
283
|
+
if (!range) throw new Error('range select not rendered')
|
|
284
|
+
|
|
285
|
+
await act(async () => {
|
|
286
|
+
range.value = range.options[range.options.length - 1].value
|
|
287
|
+
range.dispatchEvent(new Event('change', { bubbles: true }))
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
expect(opening?.closed).toBe(false)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('with auto-stream on, the open stream is not replaced by a snapshot', async () => {
|
|
294
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
295
|
+
await renderViewer(fetchLogs, true)
|
|
296
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
297
|
+
expect(fetchLogs).not.toHaveBeenCalled()
|
|
298
|
+
expect(button('Stop')).toBeTruthy()
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('a Previous answer that arrives after the stream started does not cover it', async () => {
|
|
302
|
+
let reject: (err: Error) => void = () => {}
|
|
303
|
+
const fetchLogs = vi.fn((params: LogsFetchParams) =>
|
|
304
|
+
params.previous
|
|
305
|
+
? new Promise<{ [container: string]: string }>((_, r) => { reject = r })
|
|
306
|
+
: Promise.resolve({ app: '' }))
|
|
307
|
+
await renderViewer(fetchLogs)
|
|
308
|
+
await act(async () => previousCheckbox().click())
|
|
309
|
+
await act(async () => button('Stream').click())
|
|
310
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
311
|
+
await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-13T10:00:00Z', content: 'live line', container: 'app' }))
|
|
312
|
+
// Appends are batched to the next animation frame.
|
|
313
|
+
await act(async () => { await new Promise(resolve => requestAnimationFrame(() => resolve(null))) })
|
|
314
|
+
await act(async () => reject(new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })))
|
|
315
|
+
|
|
316
|
+
expect(element.textContent).not.toContain(NO_EARLIER_RUN)
|
|
317
|
+
// The live line is still in the buffer: the level chips count it.
|
|
318
|
+
expect(element.textContent).toContain('OTHER 1')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('a Previous answer replaces an earlier stream failure', async () => {
|
|
322
|
+
const fetchLogs = vi.fn(async (params: LogsFetchParams) => {
|
|
323
|
+
if (params.previous) throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })
|
|
324
|
+
return { app: '' }
|
|
325
|
+
})
|
|
326
|
+
await renderViewer(fetchLogs)
|
|
327
|
+
await act(async () => button('Stream').click())
|
|
328
|
+
await act(async () => FakeEventSource.last?.emit('error', { error: 'stream dropped' }))
|
|
329
|
+
expect(element.textContent).toContain('stream dropped')
|
|
330
|
+
|
|
331
|
+
await act(async () => previousCheckbox().click())
|
|
332
|
+
expect(element.textContent).toContain(NO_EARLIER_RUN)
|
|
333
|
+
expect(element.textContent).not.toContain('stream dropped')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('shows a Kubernetes answer in the empty-pane style, not as an error', async () => {
|
|
337
|
+
await renderViewer(async () => { throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' }) })
|
|
338
|
+
expect(element.textContent).toContain(NO_EARLIER_RUN)
|
|
339
|
+
expect(messageClass(NO_EARLIER_RUN)).not.toMatch(/text-red/)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('shows the live stream\'s Kubernetes answer in the empty-pane style, and a stream failure as an error', async () => {
|
|
343
|
+
const unavailable = 'Kubernetes did not return this container\'s logs'
|
|
344
|
+
await renderViewer(async () => ({ app: '' }))
|
|
345
|
+
await act(async () => button('Stream').click())
|
|
346
|
+
await act(async () => FakeEventSource.last?.emit('error', { error: unavailable, error_code: 'logs_unavailable' }))
|
|
347
|
+
expect(messageClass(unavailable)).not.toMatch(/text-red/)
|
|
348
|
+
|
|
349
|
+
await act(async () => button('Stream').click())
|
|
350
|
+
await act(async () => FakeEventSource.last?.emit('error', { error: 'stream dropped' }))
|
|
351
|
+
expect(messageClass('stream dropped')).toMatch(/text-red/)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('a snapshot load replaces an ended stream\'s answer even when it succeeds empty', async () => {
|
|
355
|
+
const unavailable = 'Kubernetes did not return this container\'s logs'
|
|
356
|
+
await renderViewer(async () => ({ app: '' }))
|
|
357
|
+
await act(async () => button('Stream').click())
|
|
358
|
+
await act(async () => FakeEventSource.last?.emit('error', { error: unavailable, error_code: 'logs_unavailable' }))
|
|
359
|
+
expect(element.textContent).toContain(unavailable)
|
|
360
|
+
|
|
361
|
+
await act(async () => previousCheckbox().click())
|
|
362
|
+
expect(element.textContent).not.toContain(unavailable)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('still shows a Radar failure as an error', async () => {
|
|
366
|
+
const failure = 'Failed to fetch logs: connection refused'
|
|
367
|
+
await renderViewer(async () => { throw new ApiError(failure, { error: failure }) })
|
|
368
|
+
expect(messageClass(failure)).toMatch(/text-red/)
|
|
369
|
+
})
|
|
@@ -40,6 +40,22 @@ export interface LogsViewerProps {
|
|
|
40
40
|
autoStream?: boolean
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Kubernetes' answers that there are no logs to show, such as "no earlier run".
|
|
44
|
+
// They describe the pod, not a failure in Radar, so they are shown like an
|
|
45
|
+
// empty pane rather than as an error.
|
|
46
|
+
const CLUSTER_ANSWER_CODES = new Set(['no_previous_run', 'logs_unavailable'])
|
|
47
|
+
|
|
48
|
+
function isClusterAnswerCode(code: unknown): boolean {
|
|
49
|
+
return typeof code === 'string' && CLUSTER_ANSWER_CODES.has(code)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clusterAnswerOf(err: unknown): string | null {
|
|
53
|
+
if (!(err instanceof Error) || !('data' in err)) return null
|
|
54
|
+
const { data } = err
|
|
55
|
+
if (typeof data !== 'object' || data === null || !('error_code' in data)) return null
|
|
56
|
+
return isClusterAnswerCode(data.error_code) ? err.message : null
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
export function LogsViewer({
|
|
44
60
|
namespace: _namespace,
|
|
45
61
|
podName,
|
|
@@ -55,13 +71,17 @@ export function LogsViewer({
|
|
|
55
71
|
const [selectedContainer, setSelectedContainer] = useState(initialContainer || containers[0] || '')
|
|
56
72
|
const [isLoading, setIsLoading] = useState(false)
|
|
57
73
|
const [fetchError, setFetchError] = useState<string | null>(null)
|
|
74
|
+
const [emptyMessage, setEmptyMessage] = useState<string | null>(null)
|
|
58
75
|
const [logRange, setLogRange] = useState('500')
|
|
59
76
|
const [showPrevious, setShowPrevious] = useState(false)
|
|
60
77
|
const { showError, showSuccess } = useToast()
|
|
61
78
|
|
|
62
79
|
const { tailLines, sinceSeconds } = parseLogRange(logRange)
|
|
63
|
-
const { entries, append, set, clear } = useLogBuffer()
|
|
64
|
-
const { isStreaming, streamError, connecting, startStreaming, stopStreaming } = useLogStream()
|
|
80
|
+
const { entries, isFollowing, pendingLineCount, retainedLineCount, missingLineCount, getRetainedEntries, setFollowing, append, set, clear } = useLogBuffer()
|
|
81
|
+
const { isStreaming, streamError, streamErrorCode, connecting, startStreaming, stopStreaming } = useLogStream()
|
|
82
|
+
const selectionKey = JSON.stringify([_namespace, podName, selectedContainer, logRange])
|
|
83
|
+
// Identify the selection that supplied the buffered snapshot or stream.
|
|
84
|
+
const bufferSelectionRef = useRef<{ key: string; previous: boolean } | null>(null)
|
|
65
85
|
|
|
66
86
|
const willAutoStream = autoStream && !!createStream
|
|
67
87
|
// Tracks the container we've already auto-started for, so re-renders don't
|
|
@@ -69,33 +89,63 @@ export function LogsViewer({
|
|
|
69
89
|
const autoStartedForRef = useRef<string | null>(null)
|
|
70
90
|
// Once the user explicitly Stops, don't auto-resume for this viewer's lifetime.
|
|
71
91
|
const userStoppedRef = useRef(false)
|
|
92
|
+
// Set when starting a stream unticks Previous, so that change does not also
|
|
93
|
+
// load a snapshot over the stream.
|
|
94
|
+
const skipSnapshotRef = useRef(false)
|
|
95
|
+
// Numbers each snapshot load; a load superseded by a newer one or by a
|
|
96
|
+
// stream start must not write over what replaced it.
|
|
97
|
+
const loadSeqRef = useRef(0)
|
|
98
|
+
// Whether a stream is open or opening, read where its end must not itself
|
|
99
|
+
// trigger anything.
|
|
100
|
+
const streamOpenRef = useRef(false)
|
|
101
|
+
useEffect(() => { streamOpenRef.current = isStreaming || connecting }, [isStreaming, connecting])
|
|
72
102
|
|
|
73
103
|
const loadLogs = useCallback(async () => {
|
|
74
104
|
if (!selectedContainer) return
|
|
105
|
+
const seq = ++loadSeqRef.current
|
|
106
|
+
// The snapshot now owns the pane, so an ended stream's answer or failure
|
|
107
|
+
// must not outlive it. A stream still open or opening is left alone.
|
|
108
|
+
if (!streamOpenRef.current) stopStreaming()
|
|
75
109
|
setIsLoading(true)
|
|
76
110
|
setFetchError(null)
|
|
111
|
+
setEmptyMessage(null)
|
|
77
112
|
try {
|
|
78
113
|
const data = await fetchLogs({ container: selectedContainer, tailLines, sinceSeconds, previous: showPrevious })
|
|
114
|
+
if (seq !== loadSeqRef.current) return
|
|
115
|
+
bufferSelectionRef.current = { key: selectionKey, previous: showPrevious }
|
|
79
116
|
const logText = data[selectedContainer] ?? Object.values(data)[0] ?? ''
|
|
80
117
|
set(logText.split('\n').filter(Boolean).map(line => {
|
|
81
118
|
const { timestamp, content } = parseLogLine(line)
|
|
82
119
|
return { timestamp, content, container: selectedContainer }
|
|
83
120
|
}))
|
|
84
121
|
} catch (err) {
|
|
122
|
+
if (seq !== loadSeqRef.current) return
|
|
123
|
+
const answer = clusterAnswerOf(err)
|
|
124
|
+
if (answer) {
|
|
125
|
+
set([])
|
|
126
|
+
setEmptyMessage(answer)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
85
129
|
console.error('Failed to fetch logs:', err)
|
|
130
|
+
if (bufferSelectionRef.current?.key !== selectionKey || bufferSelectionRef.current.previous !== showPrevious) set([])
|
|
86
131
|
setFetchError(err instanceof Error ? err.message : 'Failed to fetch logs')
|
|
87
132
|
} finally {
|
|
88
|
-
setIsLoading(false)
|
|
133
|
+
if (seq === loadSeqRef.current) setIsLoading(false)
|
|
89
134
|
}
|
|
90
|
-
}, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set])
|
|
135
|
+
}, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set, stopStreaming, selectionKey])
|
|
91
136
|
|
|
92
137
|
// When auto-streaming the stream supplies the initial tail, so the static
|
|
93
138
|
// snapshot fetch is skipped to avoid a redundant request and a flash of
|
|
94
139
|
// snapshot content before the stream takes over. If the user has Stopped we
|
|
95
140
|
// won't auto-start, so fall back to the snapshot — otherwise a container
|
|
96
|
-
// switch would keep showing the previous container's lines.
|
|
141
|
+
// switch would keep showing the previous container's lines. Once the stream
|
|
142
|
+
// has ended by itself, Previous and the range load a snapshot too.
|
|
97
143
|
useEffect(() => {
|
|
98
|
-
if (
|
|
144
|
+
if (skipSnapshotRef.current) {
|
|
145
|
+
skipSnapshotRef.current = false
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (!willAutoStream || userStoppedRef.current || !streamOpenRef.current) loadLogs()
|
|
99
149
|
}, [loadLogs, willAutoStream])
|
|
100
150
|
useEffect(() => { stopStreaming() }, [selectedContainer, stopStreaming])
|
|
101
151
|
|
|
@@ -109,10 +159,23 @@ export function LogsViewer({
|
|
|
109
159
|
|
|
110
160
|
const handleStartStreaming = useCallback(() => {
|
|
111
161
|
if (!createStream) return
|
|
162
|
+
// The stream always shows the current run, so starting one leaves the
|
|
163
|
+
// Previous view without loading a snapshot over the stream. The updater
|
|
164
|
+
// reads Previous so this callback does not depend on it: a new identity
|
|
165
|
+
// would re-run the auto-start effect and reopen the stream.
|
|
166
|
+
setShowPrevious(previous => {
|
|
167
|
+
if (previous) skipSnapshotRef.current = true
|
|
168
|
+
return false
|
|
169
|
+
})
|
|
170
|
+
loadSeqRef.current++
|
|
171
|
+
setIsLoading(false)
|
|
172
|
+
setFetchError(null)
|
|
173
|
+
setEmptyMessage(null)
|
|
112
174
|
// The stream replays the last N lines (TailLines + Follow); clear first so
|
|
113
175
|
// they don't duplicate lines already in the buffer (the snapshot on the
|
|
114
176
|
// manual path, or an earlier stream on restart).
|
|
115
177
|
clear()
|
|
178
|
+
bufferSelectionRef.current = { key: selectionKey, previous: false }
|
|
116
179
|
startStreaming(
|
|
117
180
|
() => createStream({ container: selectedContainer, tailLines: 100, sinceSeconds }),
|
|
118
181
|
{
|
|
@@ -124,7 +187,7 @@ export function LogsViewer({
|
|
|
124
187
|
},
|
|
125
188
|
'Log stream connection failed',
|
|
126
189
|
)
|
|
127
|
-
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append, clear])
|
|
190
|
+
}, [createStream, startStreaming, selectedContainer, sinceSeconds, append, clear, selectionKey])
|
|
128
191
|
|
|
129
192
|
const handleStopStreaming = useCallback(() => {
|
|
130
193
|
userStoppedRef.current = true
|
|
@@ -134,13 +197,15 @@ export function LogsViewer({
|
|
|
134
197
|
useEffect(() => {
|
|
135
198
|
if (!willAutoStream || !selectedContainer) return
|
|
136
199
|
if (userStoppedRef.current) return
|
|
137
|
-
if (autoStartedForRef.current
|
|
138
|
-
|
|
139
|
-
|
|
200
|
+
if (autoStartedForRef.current !== selectedContainer) {
|
|
201
|
+
autoStartedForRef.current = selectedContainer
|
|
202
|
+
handleStartStreaming()
|
|
203
|
+
}
|
|
140
204
|
// Reset the arm latch on teardown so a re-run re-streams — without this,
|
|
141
205
|
// React Strict Mode's mount→unmount→mount closes the stream but the latch
|
|
142
|
-
// stays set, leaving the viewer static.
|
|
143
|
-
|
|
206
|
+
// stays set, leaving the viewer static. Only while a stream is open: one
|
|
207
|
+
// that ended by itself stays ended, and a range change loads a snapshot.
|
|
208
|
+
return () => { if (streamOpenRef.current) autoStartedForRef.current = null }
|
|
144
209
|
}, [willAutoStream, selectedContainer, handleStartStreaming])
|
|
145
210
|
|
|
146
211
|
const downloadLogs = useCallback(({ content, mime, extension }: LogExportPayload) => {
|
|
@@ -179,21 +244,34 @@ export function LogsViewer({
|
|
|
179
244
|
// While the auto-stream is opening (before it first settles), show the
|
|
180
245
|
// loading state rather than the empty-logs placeholder.
|
|
181
246
|
const isConnecting = willAutoStream && connecting && entries.length === 0
|
|
247
|
+
const streamAnswer = isClusterAnswerCode(streamErrorCode) ? streamError : null
|
|
182
248
|
|
|
183
249
|
return (
|
|
250
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
251
|
+
{(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>}
|
|
252
|
+
<div className="min-h-0 flex-1">
|
|
184
253
|
<LogCore
|
|
185
254
|
entries={entries}
|
|
255
|
+
isFollowing={isFollowing}
|
|
256
|
+
onFollowingChange={setFollowing}
|
|
257
|
+
pendingLineCount={pendingLineCount}
|
|
258
|
+
retainedLineCount={retainedLineCount}
|
|
259
|
+
missingLineCount={missingLineCount}
|
|
260
|
+
getRetainedEntries={getRetainedEntries}
|
|
186
261
|
isLoading={isLoading || isConnecting}
|
|
187
|
-
errorMessage={
|
|
262
|
+
errorMessage={entries.length === 0 ? fetchError || (!emptyMessage && !streamAnswer ? streamError : null) : null}
|
|
188
263
|
isStreaming={isStreaming}
|
|
189
264
|
onStartStream={createStream ? handleStartStreaming : undefined}
|
|
190
265
|
onStopStream={handleStopStreaming}
|
|
191
266
|
onRefresh={loadLogs}
|
|
192
267
|
onDownload={downloadLogs}
|
|
193
268
|
onClear={clear}
|
|
269
|
+
emptyMessage={emptyMessage ?? streamAnswer ?? undefined}
|
|
194
270
|
toolbarExtra={renderToolbarExtra}
|
|
195
271
|
forceDark={forceDark}
|
|
196
272
|
defaultDark={defaultDark}
|
|
197
273
|
/>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
198
276
|
)
|
|
199
277
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
4
|
+
import { createRoot } from 'react-dom/client'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { StructuredLogLine } from './StructuredLogLine'
|
|
7
|
+
|
|
8
|
+
const render = (content: string) => renderToStaticMarkup(<StructuredLogLine content={content} level="info" wordWrap={false} />)
|
|
9
|
+
|
|
10
|
+
describe('StructuredLogLine summary', () => {
|
|
11
|
+
it('shows a CloudNativePG PostgreSQL record by its own message and severity', () => {
|
|
12
|
+
const html = render(
|
|
13
|
+
JSON.stringify({
|
|
14
|
+
level: 'info',
|
|
15
|
+
logger: 'postgres',
|
|
16
|
+
msg: 'record',
|
|
17
|
+
record: { error_severity: 'FATAL', message: 'terminating connection due to administrator command' },
|
|
18
|
+
}),
|
|
19
|
+
)
|
|
20
|
+
expect(html).toContain('terminating connection due to administrator command')
|
|
21
|
+
expect(html).toContain('FATAL')
|
|
22
|
+
expect(html).not.toContain('>record<')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('keeps msg for ordinary structured lines', () => {
|
|
26
|
+
const html = render(JSON.stringify({ level: 'info', msg: 'Fencing status changed', record: 'not an object' }))
|
|
27
|
+
expect(html).toContain('Fencing status changed')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
{ msg: 'record' },
|
|
32
|
+
{ logger: 'worker', msg: 'record' },
|
|
33
|
+
{ logger: 'postgres', msg: 'wrapper message' },
|
|
34
|
+
])('keeps unrelated record fields out of the summary: %j', (fields) => {
|
|
35
|
+
const html = render(JSON.stringify({ level: 'info', ...fields, record: { error_severity: 'FATAL', message: 'unrelated nested text' } }))
|
|
36
|
+
expect(html).toContain(`>${fields.msg}<`)
|
|
37
|
+
expect(html).toContain('INFO')
|
|
38
|
+
expect(html).not.toContain('unrelated nested text')
|
|
39
|
+
expect(html).not.toContain('FATAL')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('requires PostgreSQL record evidence before selecting a nested message', () => {
|
|
43
|
+
const html = render(JSON.stringify({ level: 'info', logger: 'postgres', msg: 'record', record: { message: 'unverified nested text' } }))
|
|
44
|
+
expect(html).toContain('>record<')
|
|
45
|
+
expect(html).not.toContain('unverified nested text')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it.each([false, true])('keeps annotations intact with word wrapping, expanded=%s', (expanded) => {
|
|
50
|
+
const html = renderToStaticMarkup(<StructuredLogLine content={JSON.stringify({ level: 'info', msg: 'normal words', token: 'x'.repeat(400) })} level="info" wordWrap defaultExpanded={expanded} />)
|
|
51
|
+
expect(html).toMatch(/inline-block whitespace-nowrap[^>]*>\{3 fields\}/)
|
|
52
|
+
expect(html).toContain('[overflow-wrap:anywhere]')
|
|
53
|
+
expect(html).not.toContain('break-all')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('filters expanded strings, numbers and booleans by their displayed value', () => {
|
|
57
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
58
|
+
const filter = vi.fn()
|
|
59
|
+
const message = 'true null 123 "quoted"\nnext'
|
|
60
|
+
const host = document.createElement('div')
|
|
61
|
+
const root = createRoot(host)
|
|
62
|
+
try {
|
|
63
|
+
act(() => root.render(<StructuredLogLine content={JSON.stringify({ msg: message, number: -12.5, ready: true, empty: null, html: '<script>unsafe</script>' })} level="info" wordWrap defaultExpanded onFilterValue={filter} />))
|
|
64
|
+
const buttons = [...host.querySelectorAll('button')]
|
|
65
|
+
for (const value of [message, '-12.5', 'true', '<script>unsafe</script>']) {
|
|
66
|
+
const button = buttons.find((button) => button.getAttribute('aria-label') === `Filter to lines containing ${value}`)
|
|
67
|
+
expect(button).toBeDefined()
|
|
68
|
+
act(() => button!.click())
|
|
69
|
+
expect(filter).toHaveBeenLastCalledWith(value)
|
|
70
|
+
}
|
|
71
|
+
expect(host.querySelector('script')).toBeNull()
|
|
72
|
+
expect(buttons.some((button) => button.getAttribute('aria-label') === 'Filter to lines containing null')).toBe(false)
|
|
73
|
+
} finally {
|
|
74
|
+
act(() => root.unmount())
|
|
75
|
+
}
|
|
76
|
+
})
|