@skyhook-io/k8s-ui 1.17.0 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/package.json +3 -3
- 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 +6 -2
- 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 +115 -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 +24 -71
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
- 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 +17 -0
- package/src/components/gitops/tree/tree-helpers.ts +7 -1
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +40 -5
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -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 +49 -1
- package/src/components/issues/severity.ts +21 -0
- package/src/components/issues/types.ts +23 -0
- package/src/components/logs/LogCore.theme.test.tsx +81 -3
- package/src/components/logs/LogCore.tsx +25 -17
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +291 -0
- package/src/components/logs/LogsViewer.tsx +72 -11
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +22 -30
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- 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/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 +149 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +302 -109
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- 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/EndpointSliceRenderer.test.tsx +39 -1
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
- 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.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +41 -29
- 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 +82 -6
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +30 -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/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +11 -4
- package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
- package/src/components/shared/index.ts +2 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
- 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/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 +3 -1
- 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 +180 -64
- 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 +29 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +10 -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
|
@@ -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,14 @@ 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
80
|
const { entries, append, set, clear } = useLogBuffer()
|
|
64
|
-
const { isStreaming, streamError, connecting, startStreaming, stopStreaming } = useLogStream()
|
|
81
|
+
const { isStreaming, streamError, streamErrorCode, connecting, startStreaming, stopStreaming } = useLogStream()
|
|
65
82
|
|
|
66
83
|
const willAutoStream = autoStream && !!createStream
|
|
67
84
|
// Tracks the container we've already auto-started for, so re-renders don't
|
|
@@ -69,33 +86,61 @@ export function LogsViewer({
|
|
|
69
86
|
const autoStartedForRef = useRef<string | null>(null)
|
|
70
87
|
// Once the user explicitly Stops, don't auto-resume for this viewer's lifetime.
|
|
71
88
|
const userStoppedRef = useRef(false)
|
|
89
|
+
// Set when starting a stream unticks Previous, so that change does not also
|
|
90
|
+
// load a snapshot over the stream.
|
|
91
|
+
const skipSnapshotRef = useRef(false)
|
|
92
|
+
// Numbers each snapshot load; a load superseded by a newer one or by a
|
|
93
|
+
// stream start must not write over what replaced it.
|
|
94
|
+
const loadSeqRef = useRef(0)
|
|
95
|
+
// Whether a stream is open or opening, read where its end must not itself
|
|
96
|
+
// trigger anything.
|
|
97
|
+
const streamOpenRef = useRef(false)
|
|
98
|
+
useEffect(() => { streamOpenRef.current = isStreaming || connecting }, [isStreaming, connecting])
|
|
72
99
|
|
|
73
100
|
const loadLogs = useCallback(async () => {
|
|
74
101
|
if (!selectedContainer) return
|
|
102
|
+
const seq = ++loadSeqRef.current
|
|
103
|
+
// The snapshot now owns the pane, so an ended stream's answer or failure
|
|
104
|
+
// must not outlive it. A stream still open or opening is left alone.
|
|
105
|
+
if (!streamOpenRef.current) stopStreaming()
|
|
75
106
|
setIsLoading(true)
|
|
76
107
|
setFetchError(null)
|
|
108
|
+
setEmptyMessage(null)
|
|
77
109
|
try {
|
|
78
110
|
const data = await fetchLogs({ container: selectedContainer, tailLines, sinceSeconds, previous: showPrevious })
|
|
111
|
+
if (seq !== loadSeqRef.current) return
|
|
79
112
|
const logText = data[selectedContainer] ?? Object.values(data)[0] ?? ''
|
|
80
113
|
set(logText.split('\n').filter(Boolean).map(line => {
|
|
81
114
|
const { timestamp, content } = parseLogLine(line)
|
|
82
115
|
return { timestamp, content, container: selectedContainer }
|
|
83
116
|
}))
|
|
84
117
|
} catch (err) {
|
|
118
|
+
if (seq !== loadSeqRef.current) return
|
|
119
|
+
const answer = clusterAnswerOf(err)
|
|
120
|
+
if (answer) {
|
|
121
|
+
set([])
|
|
122
|
+
setEmptyMessage(answer)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
85
125
|
console.error('Failed to fetch logs:', err)
|
|
86
126
|
setFetchError(err instanceof Error ? err.message : 'Failed to fetch logs')
|
|
87
127
|
} finally {
|
|
88
|
-
setIsLoading(false)
|
|
128
|
+
if (seq === loadSeqRef.current) setIsLoading(false)
|
|
89
129
|
}
|
|
90
|
-
}, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set])
|
|
130
|
+
}, [selectedContainer, tailLines, sinceSeconds, showPrevious, fetchLogs, set, stopStreaming])
|
|
91
131
|
|
|
92
132
|
// When auto-streaming the stream supplies the initial tail, so the static
|
|
93
133
|
// snapshot fetch is skipped to avoid a redundant request and a flash of
|
|
94
134
|
// snapshot content before the stream takes over. If the user has Stopped we
|
|
95
135
|
// won't auto-start, so fall back to the snapshot — otherwise a container
|
|
96
|
-
// switch would keep showing the previous container's lines.
|
|
136
|
+
// switch would keep showing the previous container's lines. Once the stream
|
|
137
|
+
// has ended by itself, Previous and the range load a snapshot too.
|
|
97
138
|
useEffect(() => {
|
|
98
|
-
if (
|
|
139
|
+
if (skipSnapshotRef.current) {
|
|
140
|
+
skipSnapshotRef.current = false
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (!willAutoStream || userStoppedRef.current || !streamOpenRef.current) loadLogs()
|
|
99
144
|
}, [loadLogs, willAutoStream])
|
|
100
145
|
useEffect(() => { stopStreaming() }, [selectedContainer, stopStreaming])
|
|
101
146
|
|
|
@@ -109,6 +154,18 @@ export function LogsViewer({
|
|
|
109
154
|
|
|
110
155
|
const handleStartStreaming = useCallback(() => {
|
|
111
156
|
if (!createStream) return
|
|
157
|
+
// The stream always shows the current run, so starting one leaves the
|
|
158
|
+
// Previous view without loading a snapshot over the stream. The updater
|
|
159
|
+
// reads Previous so this callback does not depend on it: a new identity
|
|
160
|
+
// would re-run the auto-start effect and reopen the stream.
|
|
161
|
+
setShowPrevious(previous => {
|
|
162
|
+
if (previous) skipSnapshotRef.current = true
|
|
163
|
+
return false
|
|
164
|
+
})
|
|
165
|
+
loadSeqRef.current++
|
|
166
|
+
setIsLoading(false)
|
|
167
|
+
setFetchError(null)
|
|
168
|
+
setEmptyMessage(null)
|
|
112
169
|
// The stream replays the last N lines (TailLines + Follow); clear first so
|
|
113
170
|
// they don't duplicate lines already in the buffer (the snapshot on the
|
|
114
171
|
// manual path, or an earlier stream on restart).
|
|
@@ -134,13 +191,15 @@ export function LogsViewer({
|
|
|
134
191
|
useEffect(() => {
|
|
135
192
|
if (!willAutoStream || !selectedContainer) return
|
|
136
193
|
if (userStoppedRef.current) return
|
|
137
|
-
if (autoStartedForRef.current
|
|
138
|
-
|
|
139
|
-
|
|
194
|
+
if (autoStartedForRef.current !== selectedContainer) {
|
|
195
|
+
autoStartedForRef.current = selectedContainer
|
|
196
|
+
handleStartStreaming()
|
|
197
|
+
}
|
|
140
198
|
// Reset the arm latch on teardown so a re-run re-streams — without this,
|
|
141
199
|
// React Strict Mode's mount→unmount→mount closes the stream but the latch
|
|
142
|
-
// stays set, leaving the viewer static.
|
|
143
|
-
|
|
200
|
+
// stays set, leaving the viewer static. Only while a stream is open: one
|
|
201
|
+
// that ended by itself stays ended, and a range change loads a snapshot.
|
|
202
|
+
return () => { if (streamOpenRef.current) autoStartedForRef.current = null }
|
|
144
203
|
}, [willAutoStream, selectedContainer, handleStartStreaming])
|
|
145
204
|
|
|
146
205
|
const downloadLogs = useCallback(({ content, mime, extension }: LogExportPayload) => {
|
|
@@ -179,18 +238,20 @@ export function LogsViewer({
|
|
|
179
238
|
// While the auto-stream is opening (before it first settles), show the
|
|
180
239
|
// loading state rather than the empty-logs placeholder.
|
|
181
240
|
const isConnecting = willAutoStream && connecting && entries.length === 0
|
|
241
|
+
const streamAnswer = isClusterAnswerCode(streamErrorCode) ? streamError : null
|
|
182
242
|
|
|
183
243
|
return (
|
|
184
244
|
<LogCore
|
|
185
245
|
entries={entries}
|
|
186
246
|
isLoading={isLoading || isConnecting}
|
|
187
|
-
errorMessage={fetchError || (entries.length === 0 ? streamError : null)}
|
|
247
|
+
errorMessage={fetchError || (entries.length === 0 && !emptyMessage && !streamAnswer ? streamError : null)}
|
|
188
248
|
isStreaming={isStreaming}
|
|
189
249
|
onStartStream={createStream ? handleStartStreaming : undefined}
|
|
190
250
|
onStopStream={handleStopStreaming}
|
|
191
251
|
onRefresh={loadLogs}
|
|
192
252
|
onDownload={downloadLogs}
|
|
193
253
|
onClear={clear}
|
|
254
|
+
emptyMessage={emptyMessage ?? streamAnswer ?? undefined}
|
|
194
255
|
toolbarExtra={renderToolbarExtra}
|
|
195
256
|
forceDark={forceDark}
|
|
196
257
|
defaultDark={defaultDark}
|
|
@@ -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
|
+
})
|
|
@@ -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
|
) : (
|
|
@@ -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)
|
|
@@ -92,6 +101,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
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,8 +410,23 @@ 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}
|
|
@@ -406,13 +434,14 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
406
434
|
isLoading={isLoading || isConnecting}
|
|
407
435
|
isStreaming={isStreaming}
|
|
408
436
|
onStartStream={createStream ? handleStartStreaming : undefined}
|
|
437
|
+
sourceUnavailable={sourceUnavailable}
|
|
409
438
|
onStopStream={handleStopStreaming}
|
|
410
439
|
onRefresh={loadLogs}
|
|
411
440
|
onDownload={downloadLogs}
|
|
412
441
|
onClear={clear}
|
|
413
442
|
toolbarExtra={renderToolbarExtra}
|
|
414
443
|
showPodName
|
|
415
|
-
emptyMessage={emptyMessage || (pods.length === 0 ? 'No pods found' : 'No logs available')}
|
|
444
|
+
emptyMessage={pods.length === 0 && emptySourceState ? emptySourceState : emptyMessage || (pods.length === 0 ? 'No pods found' : 'No logs available')}
|
|
416
445
|
emptyCommand={emptyCommand}
|
|
417
446
|
errorMessage={entries.length === 0 ? fetchError || streamError : null}
|
|
418
447
|
forceDark={forceDark}
|
|
@@ -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
|
})
|
|
@@ -16,6 +16,17 @@ export interface LogStreamHandlers {
|
|
|
16
16
|
onEnd?: (data: unknown) => void
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function errorCodeOf(event: Event): string | null {
|
|
20
|
+
if (!(event instanceof MessageEvent) || typeof event.data !== 'string') return null
|
|
21
|
+
try {
|
|
22
|
+
const data: unknown = JSON.parse(event.data)
|
|
23
|
+
if (typeof data === 'object' && data !== null && 'error_code' in data && typeof data.error_code === 'string') return data.error_code
|
|
24
|
+
} catch {
|
|
25
|
+
// Not JSON: no code.
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
/**
|
|
20
31
|
* Manages an SSE log stream: EventSource lifecycle, isStreaming state, cleanup.
|
|
21
32
|
* Callers provide a factory function that creates the EventSource with current params.
|
|
@@ -24,6 +35,8 @@ export function useLogStream() {
|
|
|
24
35
|
const [isStreaming, setIsStreaming] = useState(false)
|
|
25
36
|
// Set when the connection fails (and not on a clean end — see endedRef).
|
|
26
37
|
const [streamError, setStreamError] = useState<string | null>(null)
|
|
38
|
+
// The server's error_code for that failure, when it sent one.
|
|
39
|
+
const [streamErrorCode, setStreamErrorCode] = useState<string | null>(null)
|
|
27
40
|
// True from a start attempt until the stream first settles (connected / end /
|
|
28
41
|
// error / stop). Lets callers show a connecting spinner that won't reappear
|
|
29
42
|
// after a clean end. Starts true so an auto-stream viewer paints the spinner
|
|
@@ -40,6 +53,7 @@ export function useLogStream() {
|
|
|
40
53
|
setIsStreaming(false)
|
|
41
54
|
setConnecting(false)
|
|
42
55
|
setStreamError(null)
|
|
56
|
+
setStreamErrorCode(null)
|
|
43
57
|
}, [])
|
|
44
58
|
|
|
45
59
|
const startStreaming = useCallback((
|
|
@@ -50,6 +64,7 @@ export function useLogStream() {
|
|
|
50
64
|
eventSourceRef.current?.close()
|
|
51
65
|
endedRef.current = false
|
|
52
66
|
setStreamError(null)
|
|
67
|
+
setStreamErrorCode(null)
|
|
53
68
|
setConnecting(true)
|
|
54
69
|
const es = create()
|
|
55
70
|
// Ignore events from a superseded source: closing/replacing an EventSource
|
|
@@ -124,6 +139,7 @@ export function useLogStream() {
|
|
|
124
139
|
// 'end'; that's not a failure, so don't log it or surface it.
|
|
125
140
|
if (endedRef.current) return
|
|
126
141
|
setStreamError(handleSSEError(event, errorContext, () => {}))
|
|
142
|
+
setStreamErrorCode(errorCodeOf(event))
|
|
127
143
|
})
|
|
128
144
|
|
|
129
145
|
eventSourceRef.current = es
|
|
@@ -132,5 +148,5 @@ export function useLogStream() {
|
|
|
132
148
|
// Cleanup on unmount
|
|
133
149
|
useEffect(() => () => { eventSourceRef.current?.close() }, [])
|
|
134
150
|
|
|
135
|
-
return { isStreaming, streamError, connecting, startStreaming, stopStreaming }
|
|
151
|
+
return { isStreaming, streamError, streamErrorCode, connecting, startStreaming, stopStreaming }
|
|
136
152
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './problems'
|