@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
|
@@ -136,6 +136,27 @@ export function categoryLabel(category: string): string {
|
|
|
136
136
|
return CATEGORY_LABEL[category] ?? humanize(category);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// Reasons filed under a category whose label names a different operation:
|
|
140
|
+
// CNPG archiving and an unanswered scheduled run sit under backup_failed.
|
|
141
|
+
const REASON_TITLE: Record<string, string> = {
|
|
142
|
+
CNPGWALArchivingFailing: "WAL archiving failing",
|
|
143
|
+
CNPGLastBackupFailed: "Latest backup failed",
|
|
144
|
+
CNPGScheduleDestinationMissing: "Backup schedule has no destination",
|
|
145
|
+
CNPGInstanceReadinessMismatch: "Instance Pods contradict Cluster readiness",
|
|
146
|
+
CNPGPrimaryLabelMismatch: "Primary Pod label contradicts Cluster status",
|
|
147
|
+
CNPGScheduledRunNoBackup: "No successful backup since a scheduled run",
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** The title a reason has wherever it is shown, when its category's label would name the wrong operation. */
|
|
151
|
+
export function issueReasonTitle(reason: string | undefined): string | undefined {
|
|
152
|
+
return reason ? REASON_TITLE[reason] : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** An issue row's title: its category, unless the reason names it better. */
|
|
156
|
+
export function issueTitle(issue: { category: string; reason?: string }): string {
|
|
157
|
+
return issueReasonTitle(issue.reason) || categoryLabel(issue.category);
|
|
158
|
+
}
|
|
159
|
+
|
|
139
160
|
export function groupLabel(group: string): string {
|
|
140
161
|
return GROUP_LABEL[group] ?? humanize(group);
|
|
141
162
|
}
|
|
@@ -141,6 +141,26 @@ export interface IssueRecentChange {
|
|
|
141
141
|
consumed_by?: string[];
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/** One finalizer removal: a JSON Patch that tests the object UID and the
|
|
145
|
+
* finalizer at its index before removing it, previewed as dry runs. */
|
|
146
|
+
export interface IssueFinalizerRemoval {
|
|
147
|
+
finalizer: string;
|
|
148
|
+
/** patch_resource tool input, with dry_run=true. */
|
|
149
|
+
patch_resource: Record<string, unknown>;
|
|
150
|
+
/** The same patch as a kubectl server-side dry run. */
|
|
151
|
+
kubectl: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface IssueConditionalCleanup {
|
|
155
|
+
/** Lead-in the reader must confirm, e.g. "If victoria-metrics-operator was
|
|
156
|
+
* uninstalled on purpose (…), remove the finalizer". */
|
|
157
|
+
condition: string;
|
|
158
|
+
/** For agents: when the cleanup may be proposed. */
|
|
159
|
+
agent_guidance: string;
|
|
160
|
+
caution: string;
|
|
161
|
+
removals: IssueFinalizerRemoval[];
|
|
162
|
+
}
|
|
163
|
+
|
|
144
164
|
/** "No tracked non-status changes in the window" — the claim is scoped by
|
|
145
165
|
* window_seconds so a change just outside it can't be misread as absent. */
|
|
146
166
|
export interface IssueNoRecentChanges {
|
|
@@ -188,6 +208,9 @@ export interface Issue {
|
|
|
188
208
|
action?: string;
|
|
189
209
|
remediation_kind?: string;
|
|
190
210
|
remediation_target?: string;
|
|
211
|
+
/** A cleanup that is right only when its condition holds — never part of
|
|
212
|
+
* `action`. Shown collapsed, labeled with the condition. */
|
|
213
|
+
conditional_cleanup?: IssueConditionalCleanup;
|
|
191
214
|
/** Controller-operation retry count (e.g. Argo's "(retried N times)") —
|
|
192
215
|
* distinct from restart_count (pod restarts). stuck = not expected to
|
|
193
216
|
* self-recover. */
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { act } from 'react'
|
|
2
|
+
import { act, type ComponentProps, type ReactNode } from 'react'
|
|
3
3
|
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
5
|
import { LogCore } from './LogCore'
|
|
6
6
|
|
|
7
|
+
vi.mock('react-virtuoso', () => ({ Virtuoso: ({ data, itemContent }: { data: unknown[]; itemContent: (index: number, value: unknown) => ReactNode }) => <div>{data.map((value, index) => <div key={index}>{itemContent(index, value)}</div>)}</div> }))
|
|
8
|
+
|
|
7
9
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
8
10
|
|
|
9
11
|
let root: Root
|
|
@@ -20,7 +22,7 @@ afterEach(async () => {
|
|
|
20
22
|
})
|
|
21
23
|
|
|
22
24
|
const noop = () => {}
|
|
23
|
-
async function render(props:
|
|
25
|
+
async function render(props: Partial<ComponentProps<typeof LogCore>>) {
|
|
24
26
|
await act(async () =>
|
|
25
27
|
root.render(
|
|
26
28
|
<LogCore
|
|
@@ -75,3 +77,79 @@ describe('LogCore theme', () => {
|
|
|
75
77
|
expect(toggle()).toBeNull()
|
|
76
78
|
})
|
|
77
79
|
})
|
|
80
|
+
|
|
81
|
+
it('dims unavailable streaming, explains why, suppresses its hint and keyboard action', async () => {
|
|
82
|
+
vi.useFakeTimers()
|
|
83
|
+
const start = vi.fn()
|
|
84
|
+
await render({ onStartStream: start, sourceUnavailable: true })
|
|
85
|
+
const stream = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Stream')!
|
|
86
|
+
expect(stream.disabled).toBe(true)
|
|
87
|
+
expect(stream.classList.contains('disabled:opacity-50')).toBe(true)
|
|
88
|
+
expect([...element.querySelectorAll('kbd')].some((k) => k.textContent === 'S')).toBe(false)
|
|
89
|
+
act(() => { stream.parentElement!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); vi.advanceTimersByTime(500) })
|
|
90
|
+
expect(document.body.textContent).toContain('Available once an instance is running')
|
|
91
|
+
act(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' })))
|
|
92
|
+
expect(start).not.toHaveBeenCalled()
|
|
93
|
+
vi.useRealTimers()
|
|
94
|
+
})
|
|
95
|
+
it('keeps a non-CNPG caller streamable without Pods and wraps its utilities together', async () => {
|
|
96
|
+
const start = vi.fn()
|
|
97
|
+
await render({ onStartStream: start, onClear: noop, toolbarExtra: <span>Deployment nginx sources</span>, entries: [{ id: 1, content: '{"msg":"nginx started","level":"info"}', timestamp: '', container: 'nginx', pod: 'nginx-1', isJson: true, isLogfmt: false, level: 'info', levelSource: 'structured' }] })
|
|
98
|
+
const stream = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Stream')!
|
|
99
|
+
expect(stream.disabled).toBe(false)
|
|
100
|
+
expect([...element.querySelectorAll('kbd')].some((k) => k.textContent === 'S')).toBe(true)
|
|
101
|
+
act(() => stream.click())
|
|
102
|
+
expect(start).toHaveBeenCalledOnce()
|
|
103
|
+
const utilities = element.querySelector('[aria-label="Log display and utilities"]')!
|
|
104
|
+
expect(utilities.classList.contains('flex-nowrap')).toBe(true)
|
|
105
|
+
expect(utilities.classList.contains('shrink-0')).toBe(true)
|
|
106
|
+
for (const icon of ['lucide-trash-2', 'lucide-download', 'lucide-search', 'lucide-clock', 'lucide-braces']) expect(utilities.querySelector(`.${icon}`)).not.toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('still stops an existing stream when its source becomes unavailable', async () => {
|
|
110
|
+
const stop = vi.fn()
|
|
111
|
+
await render({ onStartStream: noop, onStopStream: stop, isStreaming: true, sourceUnavailable: true })
|
|
112
|
+
const button = [...element.querySelectorAll('button')].find((b) => b.textContent === 'Stop')!
|
|
113
|
+
expect(button.disabled).toBe(false)
|
|
114
|
+
act(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' })))
|
|
115
|
+
expect(stop).toHaveBeenCalledOnce()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it.each([true, false])('wraps non-CNPG plain logs at words with ANSI enabled=%s', async (ansi) => {
|
|
119
|
+
localStorage.setItem('radar-logs-ansi', String(ansi))
|
|
120
|
+
await render({ entries: [{ id: 1, timestamp: '', content: 'nginx serving requests', container: 'nginx', isJson: false, isLogfmt: false, level: 'info', levelSource: 'keyword' }] })
|
|
121
|
+
const line = [...element.querySelectorAll('span')].find((el) => el.textContent === 'nginx serving requests' && el.classList.contains('whitespace-pre-wrap'))!
|
|
122
|
+
expect(line.classList.contains('[overflow-wrap:anywhere]')).toBe(true)
|
|
123
|
+
expect(line.classList.contains('break-all')).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
it('keeps normal word wrapping when searching a structured log', async () => {
|
|
126
|
+
await render({ entries: [{ id: 1, timestamp: '', content: '{"msg":"nginx serving requests"}', container: 'nginx', isJson: true, isLogfmt: false, level: 'info', levelSource: 'structured' }] })
|
|
127
|
+
await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true })))
|
|
128
|
+
const input = element.querySelector<HTMLInputElement>('input[placeholder="Search logs..."]')!
|
|
129
|
+
await act(async () => {
|
|
130
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'nginx')
|
|
131
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
132
|
+
})
|
|
133
|
+
const highlight = element.querySelector('mark')!
|
|
134
|
+
expect(highlight).not.toBeNull()
|
|
135
|
+
expect(highlight.parentElement!.classList.contains('[overflow-wrap:anywhere]')).toBe(true)
|
|
136
|
+
expect(highlight.parentElement!.classList.contains('break-all')).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('disables empty Export and Clear while preserving Refresh and enables them after logs load', async () => {
|
|
140
|
+
await render({ onClear: noop })
|
|
141
|
+
const exportButton = () => element.querySelector<HTMLButtonElement>('[aria-label="Export logs"]')!
|
|
142
|
+
const clear = () => element.querySelector<HTMLButtonElement>('[aria-label="Clear logs"]')!
|
|
143
|
+
expect(exportButton().disabled).toBe(true)
|
|
144
|
+
expect(clear().disabled).toBe(true)
|
|
145
|
+
expect(element.querySelector<HTMLButtonElement>('[aria-label="Refresh logs"]')?.disabled).toBe(false)
|
|
146
|
+
await render({ onClear: noop, entries: [{ id: 1, container: 'postgres', level: 'info', levelSource: 'keyword', isJson: false, isLogfmt: false, content: 'hello', timestamp: '2026-10-01T00:00:00Z' }] })
|
|
147
|
+
expect(exportButton().disabled).toBe(false)
|
|
148
|
+
expect(clear().disabled).toBe(false)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('keeps Export and Clear usable when a Pod filter hides a nonempty buffer', async () => {
|
|
152
|
+
await render({ entries: [], allEntries: [{ id: 1, container: 'postgres', level: 'info', levelSource: 'keyword', isJson: false, isLogfmt: false, content: 'hello', timestamp: '' }], onClear: noop })
|
|
153
|
+
expect(element.querySelector<HTMLButtonElement>('[aria-label="Export logs"]')!.disabled).toBe(false)
|
|
154
|
+
expect(element.querySelector<HTMLButtonElement>('[aria-label="Clear logs"]')!.disabled).toBe(false)
|
|
155
|
+
})
|
|
@@ -58,7 +58,8 @@ interface LogCoreProps {
|
|
|
58
58
|
onClear?: () => void
|
|
59
59
|
toolbarExtra?: ToolbarExtraRenderer
|
|
60
60
|
showPodName?: boolean
|
|
61
|
-
emptyMessage?:
|
|
61
|
+
emptyMessage?: ReactNode
|
|
62
|
+
sourceUnavailable?: boolean
|
|
62
63
|
emptyCommand?: string | null
|
|
63
64
|
errorMessage?: string | null
|
|
64
65
|
/**
|
|
@@ -146,6 +147,7 @@ export function LogCore({
|
|
|
146
147
|
toolbarExtra,
|
|
147
148
|
showPodName = false,
|
|
148
149
|
emptyMessage = 'No logs available',
|
|
150
|
+
sourceUnavailable = false,
|
|
149
151
|
emptyCommand,
|
|
150
152
|
errorMessage,
|
|
151
153
|
forceDark,
|
|
@@ -422,7 +424,7 @@ export function LogCore({
|
|
|
422
424
|
search.open()
|
|
423
425
|
return
|
|
424
426
|
}
|
|
425
|
-
if (e.key === 's' && !e.ctrlKey && !e.metaKey && !e.altKey && onStartStream) {
|
|
427
|
+
if (e.key === 's' && !e.ctrlKey && !e.metaKey && !e.altKey && onStartStream && (isStreaming || !sourceUnavailable)) {
|
|
426
428
|
const target = e.target as HTMLElement | null
|
|
427
429
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) return
|
|
428
430
|
e.preventDefault()
|
|
@@ -432,7 +434,7 @@ export function LogCore({
|
|
|
432
434
|
}
|
|
433
435
|
window.addEventListener('keydown', handleKeyDown)
|
|
434
436
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
435
|
-
}, [search.open, onStartStream, onStopStream, isStreaming])
|
|
437
|
+
}, [search.open, onStartStream, onStopStream, isStreaming, sourceUnavailable])
|
|
436
438
|
|
|
437
439
|
const handleFollowOutput = useCallback((isAtBottom: boolean) => {
|
|
438
440
|
if (isAtBottom) return 'smooth' as const
|
|
@@ -568,15 +570,16 @@ export function LogCore({
|
|
|
568
570
|
style={{ colorScheme: isDark ? 'dark' : 'light', fontFamily: "'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace" }}
|
|
569
571
|
>
|
|
570
572
|
{/* Toolbar */}
|
|
571
|
-
<div className={`flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBg}`}>
|
|
573
|
+
<div className={`flex flex-wrap items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBg}`}>
|
|
572
574
|
{toolbarExtraNode}
|
|
573
575
|
|
|
574
576
|
{/* Stream / Stop toggle — only shown when streaming is supported */}
|
|
575
577
|
{onStartStream && (
|
|
576
|
-
<Tooltip content={isStreaming ? 'Stop streaming' : 'Start streaming'} delay={TIP_DELAY} position="bottom">
|
|
578
|
+
<Tooltip content={isStreaming ? 'Stop streaming' : sourceUnavailable ? 'Available once an instance is running' : 'Start streaming'} delay={TIP_DELAY} position="bottom">
|
|
577
579
|
<button
|
|
578
580
|
onClick={isStreaming ? onStopStream : onStartStream}
|
|
579
|
-
|
|
581
|
+
disabled={sourceUnavailable && !isStreaming}
|
|
582
|
+
className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
580
583
|
isStreaming
|
|
581
584
|
? 'bg-green-600 text-white hover:bg-green-700'
|
|
582
585
|
: `${palette.elevatedBg} ${palette.textSecondary} ${palette.hoverBg}`
|
|
@@ -592,6 +595,7 @@ export function LogCore({
|
|
|
592
595
|
<Tooltip content="Refresh logs" delay={TIP_DELAY} position="bottom">
|
|
593
596
|
<button
|
|
594
597
|
onClick={onRefresh}
|
|
598
|
+
aria-label="Refresh logs"
|
|
595
599
|
disabled={isLoading || isStreaming}
|
|
596
600
|
className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded ${palette.elevatedBg} ${palette.textSecondary} ${palette.hoverBg} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
597
601
|
>
|
|
@@ -608,7 +612,7 @@ export function LogCore({
|
|
|
608
612
|
<Tooltip key={opt.level} content={`${active ? 'Hide' : 'Show'} ${opt.noun}`} delay={TIP_DELAY} position="bottom">
|
|
609
613
|
<button
|
|
610
614
|
onClick={() => toggleLevel(opt.level)}
|
|
611
|
-
className={`px-1.5 py-0.5 text-[10px] font-medium rounded border transition-colors ${
|
|
615
|
+
className={`px-1.5 py-0.5 text-[10px] font-medium rounded border whitespace-nowrap transition-colors ${
|
|
612
616
|
active
|
|
613
617
|
? getLevelActiveColor(opt.level, palette)
|
|
614
618
|
: levelChipInactive
|
|
@@ -621,7 +625,7 @@ export function LogCore({
|
|
|
621
625
|
})}
|
|
622
626
|
</div>
|
|
623
627
|
|
|
624
|
-
<div className="flex-
|
|
628
|
+
<div role="group" aria-label="Log display and utilities" className="ml-auto flex shrink-0 flex-nowrap items-center gap-2">
|
|
625
629
|
|
|
626
630
|
{/* Structured-log display mode: icon cycles compact→expanded→raw, chevron picks explicitly. */}
|
|
627
631
|
{hasStructuredEntries && (
|
|
@@ -801,14 +805,15 @@ export function LogCore({
|
|
|
801
805
|
|
|
802
806
|
{/* Export */}
|
|
803
807
|
<div className="relative flex items-center" ref={downloadMenuRef}>
|
|
804
|
-
<Tooltip content=
|
|
808
|
+
<Tooltip content={bufferEntries.length === 0 ? 'No logs loaded' : 'Export logs'} delay={TIP_DELAY} position="bottom" disabled={showDownloadMenu} preserveWrapperWhenDisabled>
|
|
805
809
|
<button
|
|
806
810
|
ref={exportTriggerRef}
|
|
811
|
+
disabled={bufferEntries.length === 0}
|
|
807
812
|
onClick={toggleExportMenu}
|
|
808
813
|
aria-label="Export logs"
|
|
809
814
|
aria-haspopup="dialog"
|
|
810
815
|
aria-expanded={showDownloadMenu}
|
|
811
|
-
className={iconBtnInactive}
|
|
816
|
+
className={`${iconBtnInactive} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
812
817
|
>
|
|
813
818
|
<Download className="w-4 h-4" />
|
|
814
819
|
</button>
|
|
@@ -884,15 +889,18 @@ export function LogCore({
|
|
|
884
889
|
|
|
885
890
|
{/* Clear */}
|
|
886
891
|
{onClear && (
|
|
887
|
-
<Tooltip content=
|
|
892
|
+
<Tooltip content={bufferEntries.length === 0 ? 'No logs loaded' : 'Clear logs'} delay={TIP_DELAY} position="bottom">
|
|
888
893
|
<button
|
|
889
894
|
onClick={onClear}
|
|
890
|
-
|
|
895
|
+
aria-label="Clear logs"
|
|
896
|
+
disabled={bufferEntries.length === 0}
|
|
897
|
+
className={`${iconBtnInactive} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
891
898
|
>
|
|
892
899
|
<Trash2 className="w-4 h-4" />
|
|
893
900
|
</button>
|
|
894
901
|
</Tooltip>
|
|
895
902
|
)}
|
|
903
|
+
</div>
|
|
896
904
|
</div>
|
|
897
905
|
|
|
898
906
|
{/* Search bar */}
|
|
@@ -1007,7 +1015,7 @@ export function LogCore({
|
|
|
1007
1015
|
) : groupedEntries.length === 0 ? (
|
|
1008
1016
|
<div className={`${EMPTY_STATE_CLASS} ${palette.textTertiary}`}>
|
|
1009
1017
|
<Terminal className="w-8 h-8" />
|
|
1010
|
-
<
|
|
1018
|
+
<div>{entries.length > 0 ? `Filters hide all ${entries.length.toLocaleString()} loaded lines` : emptyMessage}</div>
|
|
1011
1019
|
{entries.length === 0 && emptyCommand && (
|
|
1012
1020
|
<button type="button" onClick={() => { void copyText(emptyCommand) }} className={`mt-2 inline-flex max-w-[80%] items-center gap-2 rounded border px-3 py-2 font-mono text-xs ${palette.border} ${palette.toolbarBg}`} title="Copy recovery command">
|
|
1013
1021
|
<code className="truncate">{emptyCommand}</code>
|
|
@@ -1062,7 +1070,7 @@ export function LogCore({
|
|
|
1062
1070
|
|
|
1063
1071
|
{/* Keyboard shortcut hints */}
|
|
1064
1072
|
<div className={`flex items-center gap-4 px-3 py-1 border-t ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
|
|
1065
|
-
{onStartStream && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
|
|
1073
|
+
{onStartStream && (!sourceUnavailable || isStreaming) && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
|
|
1066
1074
|
<Shortcut keys="Ctrl+F" label="Search" palette={palette} />
|
|
1067
1075
|
{search.mode !== 'hide' && (
|
|
1068
1076
|
<>
|
|
@@ -1189,7 +1197,7 @@ function LogLine({
|
|
|
1189
1197
|
const highlighted = highlightSearchMatches(plain, searchQuery, searchIsRegex, searchIsCaseSensitive)
|
|
1190
1198
|
contentElement = (
|
|
1191
1199
|
<span
|
|
1192
|
-
className={`${wordWrap ? 'whitespace-pre-wrap
|
|
1200
|
+
className={`${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'} ${levelColor}`}
|
|
1193
1201
|
dangerouslySetInnerHTML={{ __html: highlighted }}
|
|
1194
1202
|
/>
|
|
1195
1203
|
)
|
|
@@ -1209,13 +1217,13 @@ function LogLine({
|
|
|
1209
1217
|
const html = ansiToHtml(entry.content)
|
|
1210
1218
|
contentElement = (
|
|
1211
1219
|
<span
|
|
1212
|
-
className={`${wordWrap ? 'whitespace-pre-wrap
|
|
1220
|
+
className={`${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'} ${levelColor}`}
|
|
1213
1221
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
1214
1222
|
/>
|
|
1215
1223
|
)
|
|
1216
1224
|
} else {
|
|
1217
1225
|
contentElement = (
|
|
1218
|
-
<span className={`${wordWrap ? 'whitespace-pre-wrap
|
|
1226
|
+
<span className={`${wordWrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'whitespace-pre'} ${levelColor}`}>
|
|
1219
1227
|
{stripAnsi(entry.content)}
|
|
1220
1228
|
</span>
|
|
1221
1229
|
)
|
|
@@ -50,6 +50,7 @@ interface LogRangeSelectProps {
|
|
|
50
50
|
isDark?: boolean
|
|
51
51
|
/** When true, the control is greyed out and non-interactive. */
|
|
52
52
|
disabled?: boolean
|
|
53
|
+
disabledReason?: string
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export function LogRangeSelect({
|
|
@@ -59,10 +60,11 @@ export function LogRangeSelect({
|
|
|
59
60
|
tooltip = 'How many logs to load — by line count or time range',
|
|
60
61
|
isDark = true,
|
|
61
62
|
disabled = false,
|
|
63
|
+
disabledReason = 'Stop streaming to change the log range',
|
|
62
64
|
}: LogRangeSelectProps) {
|
|
63
65
|
const palette = getLogPalette(isDark)
|
|
64
66
|
return (
|
|
65
|
-
<Tooltip content={disabled ?
|
|
67
|
+
<Tooltip content={disabled ? disabledReason : tooltip} position="bottom">
|
|
66
68
|
<select
|
|
67
69
|
value={value}
|
|
68
70
|
onChange={(e) => onChange(e.target.value)}
|
|
@@ -0,0 +1,291 @@
|
|
|
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
|
+
let root: Root
|
|
33
|
+
let element: HTMLDivElement
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.stubGlobal('EventSource', FakeEventSource)
|
|
36
|
+
element = document.createElement('div')
|
|
37
|
+
document.body.appendChild(element)
|
|
38
|
+
root = createRoot(element)
|
|
39
|
+
})
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
await act(async () => root.unmount())
|
|
42
|
+
element.remove()
|
|
43
|
+
vi.unstubAllGlobals()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
async function renderViewer(fetchLogs: (params: LogsFetchParams) => Promise<{ [container: string]: string }>, autoStream = false) {
|
|
47
|
+
await act(async () =>
|
|
48
|
+
root.render(
|
|
49
|
+
<ToastProvider>
|
|
50
|
+
<LogsViewer
|
|
51
|
+
namespace="default"
|
|
52
|
+
podName="web-0"
|
|
53
|
+
containers={['app']}
|
|
54
|
+
fetchLogs={fetchLogs}
|
|
55
|
+
createStream={() => new EventSource('/stream')}
|
|
56
|
+
autoStream={autoStream}
|
|
57
|
+
/>
|
|
58
|
+
</ToastProvider>,
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function previousCheckbox(): HTMLInputElement {
|
|
64
|
+
const box = element.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
|
65
|
+
if (!box) throw new Error('Previous checkbox not rendered')
|
|
66
|
+
return box
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function button(label: string): HTMLButtonElement {
|
|
70
|
+
const found = Array.from(element.querySelectorAll('button')).find(b => b.textContent?.trim() === label)
|
|
71
|
+
if (!found) throw new Error(`${label} button not rendered`)
|
|
72
|
+
return found
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The class of the box the message sits in: the innermost element holding the
|
|
76
|
+
// text is the message itself, and its parent carries the colour.
|
|
77
|
+
function messageClass(text: string): string {
|
|
78
|
+
const message = Array.from(element.querySelectorAll('span, div')).filter(el => el.textContent === text).pop()
|
|
79
|
+
if (!message?.parentElement) throw new Error(`"${text}" not rendered`)
|
|
80
|
+
return message.parentElement.className
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it('starting a stream leaves the Previous view and its answer behind', async () => {
|
|
84
|
+
const fetchLogs = vi.fn(async (params: LogsFetchParams) => {
|
|
85
|
+
if (params.previous) throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })
|
|
86
|
+
return { app: '' }
|
|
87
|
+
})
|
|
88
|
+
await renderViewer(fetchLogs)
|
|
89
|
+
await act(async () => previousCheckbox().click())
|
|
90
|
+
expect(element.textContent).toContain(NO_EARLIER_RUN)
|
|
91
|
+
const callsBeforeStream = fetchLogs.mock.calls.length
|
|
92
|
+
|
|
93
|
+
await act(async () => button('Stream').click())
|
|
94
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
95
|
+
|
|
96
|
+
expect(element.textContent).not.toContain(NO_EARLIER_RUN)
|
|
97
|
+
expect(previousCheckbox().checked).toBe(false)
|
|
98
|
+
// Unticking Previous must not also load a snapshot over the stream.
|
|
99
|
+
expect(fetchLogs.mock.calls.length).toBe(callsBeforeStream)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('with auto-stream on, Stop, Previous, then Stream returns to the live stream', async () => {
|
|
103
|
+
const fetchLogs = vi.fn(async (params: LogsFetchParams) => {
|
|
104
|
+
if (params.previous) throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })
|
|
105
|
+
return { app: '' }
|
|
106
|
+
})
|
|
107
|
+
await renderViewer(fetchLogs, true)
|
|
108
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
109
|
+
await act(async () => button('Stop').click())
|
|
110
|
+
await act(async () => previousCheckbox().click())
|
|
111
|
+
expect(element.textContent).toContain(NO_EARLIER_RUN)
|
|
112
|
+
const callsBeforeStream = fetchLogs.mock.calls.length
|
|
113
|
+
|
|
114
|
+
await act(async () => button('Stream').click())
|
|
115
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
116
|
+
|
|
117
|
+
expect(element.textContent).not.toContain(NO_EARLIER_RUN)
|
|
118
|
+
expect(previousCheckbox().checked).toBe(false)
|
|
119
|
+
expect(button('Stop')).toBeTruthy()
|
|
120
|
+
expect(fetchLogs.mock.calls.length).toBe(callsBeforeStream)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('with auto-stream on, Previous loads the earlier run once the stream ended by itself', async () => {
|
|
124
|
+
const fetchLogs = vi.fn(async (params: LogsFetchParams) =>
|
|
125
|
+
({ app: params.previous ? '2026-10-13T10:00:00Z earlier run crashed' : '' }))
|
|
126
|
+
await renderViewer(fetchLogs, true)
|
|
127
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
128
|
+
// The container exited: the stream ends without the user pressing Stop.
|
|
129
|
+
await act(async () => FakeEventSource.last?.emit('end', {}))
|
|
130
|
+
expect(button('Stream')).toBeTruthy()
|
|
131
|
+
|
|
132
|
+
await act(async () => previousCheckbox().click())
|
|
133
|
+
|
|
134
|
+
expect(fetchLogs).toHaveBeenCalledWith(expect.objectContaining({ previous: true }))
|
|
135
|
+
// The earlier run's line is in the buffer: the level chips count it.
|
|
136
|
+
expect(element.textContent).toContain('OTHER 1')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('with auto-stream on, a time range picked after the stream ended loads it instead of reopening the stream', async () => {
|
|
140
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
141
|
+
await renderViewer(fetchLogs, true)
|
|
142
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
143
|
+
await act(async () => FakeEventSource.last?.emit('end', {}))
|
|
144
|
+
const ended = FakeEventSource.last
|
|
145
|
+
await act(async () => previousCheckbox().click())
|
|
146
|
+
|
|
147
|
+
const range = element.querySelector<HTMLSelectElement>('select')
|
|
148
|
+
if (!range) throw new Error('range select not rendered')
|
|
149
|
+
await act(async () => {
|
|
150
|
+
range.value = range.options[range.options.length - 1].value
|
|
151
|
+
range.dispatchEvent(new Event('change', { bubbles: true }))
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
expect(FakeEventSource.last).toBe(ended)
|
|
155
|
+
expect(previousCheckbox().checked).toBe(true)
|
|
156
|
+
expect(fetchLogs).toHaveBeenLastCalledWith(expect.objectContaining({ previous: true, sinceSeconds: expect.any(Number) }))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('with auto-stream on, a range picked while the stream opens reopens it with that range', async () => {
|
|
160
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
161
|
+
await renderViewer(fetchLogs, true)
|
|
162
|
+
const opening = FakeEventSource.last
|
|
163
|
+
|
|
164
|
+
const range = element.querySelector<HTMLSelectElement>('select')
|
|
165
|
+
if (!range) throw new Error('range select not rendered')
|
|
166
|
+
await act(async () => {
|
|
167
|
+
range.value = range.options[range.options.length - 1].value
|
|
168
|
+
range.dispatchEvent(new Event('change', { bubbles: true }))
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
expect(opening?.closed).toBe(true)
|
|
172
|
+
expect(FakeEventSource.last).not.toBe(opening)
|
|
173
|
+
expect(fetchLogs).not.toHaveBeenCalled()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('with auto-stream on, after the stream ended and was started again, a range picked while it opens reopens it', async () => {
|
|
177
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
178
|
+
await renderViewer(fetchLogs, true)
|
|
179
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
180
|
+
await act(async () => FakeEventSource.last?.emit('end', {}))
|
|
181
|
+
const range = element.querySelector<HTMLSelectElement>('select')
|
|
182
|
+
if (!range) throw new Error('range select not rendered')
|
|
183
|
+
const pick = (index: number) => act(async () => {
|
|
184
|
+
range.value = range.options[index].value
|
|
185
|
+
range.dispatchEvent(new Event('change', { bubbles: true }))
|
|
186
|
+
})
|
|
187
|
+
await pick(range.options.length - 1)
|
|
188
|
+
await act(async () => button('Stream').click())
|
|
189
|
+
const opening = FakeEventSource.last
|
|
190
|
+
|
|
191
|
+
await pick(range.options.length - 2)
|
|
192
|
+
|
|
193
|
+
expect(opening?.closed).toBe(true)
|
|
194
|
+
expect(FakeEventSource.last).not.toBe(opening)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('a range picked while a manually started stream opens does not cancel it', async () => {
|
|
198
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
199
|
+
await renderViewer(fetchLogs, true)
|
|
200
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
201
|
+
await act(async () => button('Stop').click())
|
|
202
|
+
await act(async () => button('Stream').click())
|
|
203
|
+
const opening = FakeEventSource.last
|
|
204
|
+
const range = element.querySelector<HTMLSelectElement>('select')
|
|
205
|
+
if (!range) throw new Error('range select not rendered')
|
|
206
|
+
|
|
207
|
+
await act(async () => {
|
|
208
|
+
range.value = range.options[range.options.length - 1].value
|
|
209
|
+
range.dispatchEvent(new Event('change', { bubbles: true }))
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
expect(opening?.closed).toBe(false)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('with auto-stream on, the open stream is not replaced by a snapshot', async () => {
|
|
216
|
+
const fetchLogs = vi.fn(async () => ({ app: '' }))
|
|
217
|
+
await renderViewer(fetchLogs, true)
|
|
218
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
219
|
+
expect(fetchLogs).not.toHaveBeenCalled()
|
|
220
|
+
expect(button('Stop')).toBeTruthy()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('a Previous answer that arrives after the stream started does not cover it', async () => {
|
|
224
|
+
let reject: (err: Error) => void = () => {}
|
|
225
|
+
const fetchLogs = vi.fn((params: LogsFetchParams) =>
|
|
226
|
+
params.previous
|
|
227
|
+
? new Promise<{ [container: string]: string }>((_, r) => { reject = r })
|
|
228
|
+
: Promise.resolve({ app: '' }))
|
|
229
|
+
await renderViewer(fetchLogs)
|
|
230
|
+
await act(async () => previousCheckbox().click())
|
|
231
|
+
await act(async () => button('Stream').click())
|
|
232
|
+
await act(async () => FakeEventSource.last?.emit('connected', {}))
|
|
233
|
+
await act(async () => FakeEventSource.last?.emit('log', { timestamp: '2026-10-13T10:00:00Z', content: 'live line', container: 'app' }))
|
|
234
|
+
// Appends are batched to the next animation frame.
|
|
235
|
+
await act(async () => { await new Promise(resolve => requestAnimationFrame(() => resolve(null))) })
|
|
236
|
+
await act(async () => reject(new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })))
|
|
237
|
+
|
|
238
|
+
expect(element.textContent).not.toContain(NO_EARLIER_RUN)
|
|
239
|
+
// The live line is still in the buffer: the level chips count it.
|
|
240
|
+
expect(element.textContent).toContain('OTHER 1')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('a Previous answer replaces an earlier stream failure', async () => {
|
|
244
|
+
const fetchLogs = vi.fn(async (params: LogsFetchParams) => {
|
|
245
|
+
if (params.previous) throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' })
|
|
246
|
+
return { app: '' }
|
|
247
|
+
})
|
|
248
|
+
await renderViewer(fetchLogs)
|
|
249
|
+
await act(async () => button('Stream').click())
|
|
250
|
+
await act(async () => FakeEventSource.last?.emit('error', { error: 'stream dropped' }))
|
|
251
|
+
expect(element.textContent).toContain('stream dropped')
|
|
252
|
+
|
|
253
|
+
await act(async () => previousCheckbox().click())
|
|
254
|
+
expect(element.textContent).toContain(NO_EARLIER_RUN)
|
|
255
|
+
expect(element.textContent).not.toContain('stream dropped')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('shows a Kubernetes answer in the empty-pane style, not as an error', async () => {
|
|
259
|
+
await renderViewer(async () => { throw new ApiError(NO_EARLIER_RUN, { error: NO_EARLIER_RUN, error_code: 'no_previous_run' }) })
|
|
260
|
+
expect(element.textContent).toContain(NO_EARLIER_RUN)
|
|
261
|
+
expect(messageClass(NO_EARLIER_RUN)).not.toMatch(/text-red/)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('shows the live stream\'s Kubernetes answer in the empty-pane style, and a stream failure as an error', async () => {
|
|
265
|
+
const unavailable = 'Kubernetes did not return this container\'s logs'
|
|
266
|
+
await renderViewer(async () => ({ app: '' }))
|
|
267
|
+
await act(async () => button('Stream').click())
|
|
268
|
+
await act(async () => FakeEventSource.last?.emit('error', { error: unavailable, error_code: 'logs_unavailable' }))
|
|
269
|
+
expect(messageClass(unavailable)).not.toMatch(/text-red/)
|
|
270
|
+
|
|
271
|
+
await act(async () => button('Stream').click())
|
|
272
|
+
await act(async () => FakeEventSource.last?.emit('error', { error: 'stream dropped' }))
|
|
273
|
+
expect(messageClass('stream dropped')).toMatch(/text-red/)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('a snapshot load replaces an ended stream\'s answer even when it succeeds empty', async () => {
|
|
277
|
+
const unavailable = 'Kubernetes did not return this container\'s logs'
|
|
278
|
+
await renderViewer(async () => ({ app: '' }))
|
|
279
|
+
await act(async () => button('Stream').click())
|
|
280
|
+
await act(async () => FakeEventSource.last?.emit('error', { error: unavailable, error_code: 'logs_unavailable' }))
|
|
281
|
+
expect(element.textContent).toContain(unavailable)
|
|
282
|
+
|
|
283
|
+
await act(async () => previousCheckbox().click())
|
|
284
|
+
expect(element.textContent).not.toContain(unavailable)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('still shows a Radar failure as an error', async () => {
|
|
288
|
+
const failure = 'Failed to fetch logs: connection refused'
|
|
289
|
+
await renderViewer(async () => { throw new ApiError(failure, { error: failure }) })
|
|
290
|
+
expect(messageClass(failure)).toMatch(/text-red/)
|
|
291
|
+
})
|