@skyhook-io/k8s-ui 1.16.3 → 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/applications/ApplicationDetail.test.tsx +15 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -15
- 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/checks/types.ts +2 -13
- 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/GitOpsTableView.tsx +9 -24
- package/src/components/gitops/detail-helpers.ts +1 -1
- 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 +29 -83
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +40 -60
- 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 +14 -3
- package/src/components/issues/issues.test.ts +50 -2
- 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.notice.test.tsx +59 -0
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +44 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +28 -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 +309 -117
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +5 -4
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
- package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
- 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 +7 -8
- 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 +9 -17
- package/src/components/resources/renderers/CNPGShared.tsx +2 -2
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- 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/ConfigAuditReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +62 -0
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +15 -14
- package/src/components/resources/renderers/EventRenderer.tsx +3 -3
- package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
- package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/HPARenderer.tsx +57 -17
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +36 -5
- 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/KarpenterNodeClaimRenderer.tsx +2 -14
- package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +15 -13
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
- 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/KustomizationRenderer.tsx +2 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
- package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
- package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
- package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
- package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
- package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +62 -36
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +9 -22
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
- 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 -24
- 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-keda.ts +1 -7
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils-kyverno.ts +1 -7
- package/src/components/resources/resource-utils-velero.ts +0 -12
- package/src/components/resources/resource-utils.ts +99 -39
- 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/DrainPlanDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
- package/src/components/shared/ResourceActionsBar.tsx +20 -24
- package/src/components/shared/ResourceRendererDispatch.tsx +13 -10
- package/src/components/shared/SetImageDialog.test.ts +75 -6
- package/src/components/shared/SetImageDialog.tsx +92 -84
- package/src/components/shared/index.ts +4 -1
- package/src/components/timeline/TimelineList.tsx +2 -15
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -11
- 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 +8 -74
- 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/ScheduleValue.test.tsx +5 -0
- package/src/components/ui/ScheduleValue.tsx +3 -2
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +175 -1
- package/src/components/ui/drawer-components.tsx +103 -36
- 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 +101 -0
- package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +297 -104
- 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 +21 -2
- package/src/types/core.ts +60 -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 +49 -2
- package/src/utils/format.ts +9 -1
- package/src/utils/gateway-references.test.ts +17 -0
- package/src/utils/gateway-references.ts +29 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/gitops-write-guard.test.ts +401 -0
- package/src/utils/gitops-write-guard.ts +509 -0
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/ingress-class-references.ts +22 -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 +70 -0
- package/src/utils/navigation.ts +91 -8
- package/src/utils/node-lifecycle.test.ts +36 -0
- package/src/utils/node-lifecycle.ts +77 -0
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- package/src/utils/resource-hierarchy.ts +41 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/resource-refs.test.ts +23 -0
- package/src/utils/resource-refs.ts +30 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
|
@@ -0,0 +1,59 @@
|
|
|
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 { WorkloadLogsViewer } from './WorkloadLogsViewer'
|
|
7
|
+
|
|
8
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
9
|
+
|
|
10
|
+
class FakeEventSource extends EventTarget {
|
|
11
|
+
static last: FakeEventSource | undefined
|
|
12
|
+
constructor(readonly url: string) {
|
|
13
|
+
super()
|
|
14
|
+
FakeEventSource.last = this
|
|
15
|
+
}
|
|
16
|
+
close() {}
|
|
17
|
+
emit(type: string, data: unknown) {
|
|
18
|
+
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) }))
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let root: Root
|
|
23
|
+
let element: HTMLDivElement
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.stubGlobal('EventSource', FakeEventSource)
|
|
26
|
+
element = document.createElement('div')
|
|
27
|
+
document.body.appendChild(element)
|
|
28
|
+
root = createRoot(element)
|
|
29
|
+
})
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await act(async () => root.unmount())
|
|
32
|
+
element.remove()
|
|
33
|
+
vi.unstubAllGlobals()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('clears the unreadable-sources notice when the stream ends', async () => {
|
|
37
|
+
const notice = '1 source could not be read: web-0/app: Kubernetes did not return this container\'s logs'
|
|
38
|
+
await act(async () =>
|
|
39
|
+
root.render(
|
|
40
|
+
<ToastProvider>
|
|
41
|
+
<WorkloadLogsViewer
|
|
42
|
+
name="web"
|
|
43
|
+
fetchAll={async () => ({ pods: [], logs: [] })}
|
|
44
|
+
createStream={() => new EventSource('/stream')}
|
|
45
|
+
autoStream
|
|
46
|
+
/>
|
|
47
|
+
</ToastProvider>,
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
const source = FakeEventSource.last
|
|
51
|
+
if (!source) throw new Error('stream was not opened')
|
|
52
|
+
|
|
53
|
+
await act(async () => source.emit('connected', { pods: [{ name: 'web-0', containers: ['app'] }] }))
|
|
54
|
+
await act(async () => source.emit('notice', { notice }))
|
|
55
|
+
expect(element.textContent).toContain(notice)
|
|
56
|
+
|
|
57
|
+
await act(async () => source.emit('end', { reason: 'no pods found' }))
|
|
58
|
+
expect(element.textContent).not.toContain(notice)
|
|
59
|
+
})
|
|
@@ -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)
|
|
@@ -246,7 +256,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
246
256
|
// while new pod logs start flowing in
|
|
247
257
|
}
|
|
248
258
|
},
|
|
259
|
+
onNotice: (data: unknown) => {
|
|
260
|
+
const notice = typeof data === 'object' && data !== null && 'notice' in data ? data.notice : ''
|
|
261
|
+
setNotice(typeof notice === 'string' ? notice : '')
|
|
262
|
+
},
|
|
249
263
|
onEnd: (data: any) => {
|
|
264
|
+
setNotice('')
|
|
250
265
|
if (data?.emptyMessage) setEmptyMessage(data.emptyMessage)
|
|
251
266
|
if (data?.command) setEmptyCommand(data.command)
|
|
252
267
|
},
|
|
@@ -312,10 +327,11 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
312
327
|
const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
|
|
313
328
|
<>
|
|
314
329
|
{/* Pod filter */}
|
|
330
|
+
<fieldset disabled={sourceUnavailable} className="flex items-center gap-2 disabled:opacity-50">
|
|
315
331
|
<div className="relative">
|
|
316
332
|
<button
|
|
317
333
|
onClick={() => setShowPodFilter(v => !v)}
|
|
318
|
-
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 ${
|
|
319
335
|
showPodFilter ? palette.toolbarActive : `${palette.elevatedBg} ${palette.textSecondary} ${palette.hoverBg}`
|
|
320
336
|
}`}
|
|
321
337
|
>
|
|
@@ -371,7 +387,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
371
387
|
containers={allContainers}
|
|
372
388
|
value={selectedContainer}
|
|
373
389
|
onChange={setSelectedContainer}
|
|
374
|
-
includeAll
|
|
390
|
+
includeAll={allowAllContainers}
|
|
375
391
|
isDark={isDark}
|
|
376
392
|
/>
|
|
377
393
|
|
|
@@ -381,8 +397,10 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
381
397
|
lineOptions={[50, 100, 500, 1000]}
|
|
382
398
|
tooltip="How many logs to load per pod — by line count or time range"
|
|
383
399
|
isDark={isDark}
|
|
384
|
-
disabled={isStreaming}
|
|
400
|
+
disabled={isStreaming || sourceUnavailable}
|
|
401
|
+
disabledReason={sourceUnavailable ? 'No log source is available yet' : undefined}
|
|
385
402
|
/>
|
|
403
|
+
</fieldset>
|
|
386
404
|
</>
|
|
387
405
|
)
|
|
388
406
|
|
|
@@ -392,8 +410,23 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
392
410
|
|
|
393
411
|
return (
|
|
394
412
|
<div className="flex h-full min-h-0 flex-col">
|
|
395
|
-
{
|
|
396
|
-
|
|
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
|
+
)}
|
|
397
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>}
|
|
398
431
|
<div className="min-h-0 flex-1"><LogCore
|
|
399
432
|
entries={filteredEntries}
|
|
@@ -401,13 +434,14 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
|
|
|
401
434
|
isLoading={isLoading || isConnecting}
|
|
402
435
|
isStreaming={isStreaming}
|
|
403
436
|
onStartStream={createStream ? handleStartStreaming : undefined}
|
|
437
|
+
sourceUnavailable={sourceUnavailable}
|
|
404
438
|
onStopStream={handleStopStreaming}
|
|
405
439
|
onRefresh={loadLogs}
|
|
406
440
|
onDownload={downloadLogs}
|
|
407
441
|
onClear={clear}
|
|
408
442
|
toolbarExtra={renderToolbarExtra}
|
|
409
443
|
showPodName
|
|
410
|
-
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')}
|
|
411
445
|
emptyCommand={emptyCommand}
|
|
412
446
|
errorMessage={entries.length === 0 ? fetchError || streamError : null}
|
|
413
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
|
})
|
|
@@ -10,10 +10,23 @@ export interface LogStreamHandlers {
|
|
|
10
10
|
onPodAdded?: (data: unknown) => void
|
|
11
11
|
/** Called when pods are terminated during streaming (workload logs only) */
|
|
12
12
|
onPodRemoved?: (data: unknown) => void
|
|
13
|
+
/** Called when the set of sources the stream cannot read changes (workload logs only) */
|
|
14
|
+
onNotice?: (data: unknown) => void
|
|
13
15
|
/** Called when the server ends the stream cleanly */
|
|
14
16
|
onEnd?: (data: unknown) => void
|
|
15
17
|
}
|
|
16
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
|
+
|
|
17
30
|
/**
|
|
18
31
|
* Manages an SSE log stream: EventSource lifecycle, isStreaming state, cleanup.
|
|
19
32
|
* Callers provide a factory function that creates the EventSource with current params.
|
|
@@ -22,6 +35,8 @@ export function useLogStream() {
|
|
|
22
35
|
const [isStreaming, setIsStreaming] = useState(false)
|
|
23
36
|
// Set when the connection fails (and not on a clean end — see endedRef).
|
|
24
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)
|
|
25
40
|
// True from a start attempt until the stream first settles (connected / end /
|
|
26
41
|
// error / stop). Lets callers show a connecting spinner that won't reappear
|
|
27
42
|
// after a clean end. Starts true so an auto-stream viewer paints the spinner
|
|
@@ -38,6 +53,7 @@ export function useLogStream() {
|
|
|
38
53
|
setIsStreaming(false)
|
|
39
54
|
setConnecting(false)
|
|
40
55
|
setStreamError(null)
|
|
56
|
+
setStreamErrorCode(null)
|
|
41
57
|
}, [])
|
|
42
58
|
|
|
43
59
|
const startStreaming = useCallback((
|
|
@@ -48,6 +64,7 @@ export function useLogStream() {
|
|
|
48
64
|
eventSourceRef.current?.close()
|
|
49
65
|
endedRef.current = false
|
|
50
66
|
setStreamError(null)
|
|
67
|
+
setStreamErrorCode(null)
|
|
51
68
|
setConnecting(true)
|
|
52
69
|
const es = create()
|
|
53
70
|
// Ignore events from a superseded source: closing/replacing an EventSource
|
|
@@ -92,6 +109,15 @@ export function useLogStream() {
|
|
|
92
109
|
}
|
|
93
110
|
})
|
|
94
111
|
|
|
112
|
+
es.addEventListener('notice', (event) => {
|
|
113
|
+
if (!isCurrent()) return
|
|
114
|
+
if (handlers.onNotice) {
|
|
115
|
+
try { handlers.onNotice(JSON.parse((event as MessageEvent).data)) } catch (e) {
|
|
116
|
+
console.error('Failed to parse notice event:', e)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
95
121
|
es.addEventListener('end', (event) => {
|
|
96
122
|
if (!isCurrent()) return
|
|
97
123
|
endedRef.current = true
|
|
@@ -113,6 +139,7 @@ export function useLogStream() {
|
|
|
113
139
|
// 'end'; that's not a failure, so don't log it or surface it.
|
|
114
140
|
if (endedRef.current) return
|
|
115
141
|
setStreamError(handleSSEError(event, errorContext, () => {}))
|
|
142
|
+
setStreamErrorCode(errorCodeOf(event))
|
|
116
143
|
})
|
|
117
144
|
|
|
118
145
|
eventSourceRef.current = es
|
|
@@ -121,5 +148,5 @@ export function useLogStream() {
|
|
|
121
148
|
// Cleanup on unmount
|
|
122
149
|
useEffect(() => () => { eventSourceRef.current?.close() }, [])
|
|
123
150
|
|
|
124
|
-
return { isStreaming, streamError, connecting, startStreaming, stopStreaming }
|
|
151
|
+
return { isStreaming, streamError, streamErrorCode, connecting, startStreaming, stopStreaming }
|
|
125
152
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './problems'
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { ProblemMeta, WorkspaceProblemCallout, ProblemList, problemOriginLabel, type WorkspaceProblem } from './problems'
|
|
4
|
+
|
|
5
|
+
const details = (kind: string) => ({
|
|
6
|
+
id: 'p',
|
|
7
|
+
tone: 'degraded' as const,
|
|
8
|
+
category: 'availability',
|
|
9
|
+
title: 'Something needs a look',
|
|
10
|
+
subject: { kind, group: 'example.io', namespace: 'ns', name: 'child-1' },
|
|
11
|
+
})
|
|
12
|
+
const problem = (kind: string): Extract<WorkspaceProblem, { source: 'issue' }> => ({ ...details(kind), source: 'issue' })
|
|
13
|
+
|
|
14
|
+
describe('ProblemMeta', () => {
|
|
15
|
+
it('names the subject only when it is not the workspace root kind', () => {
|
|
16
|
+
expect(renderToStaticMarkup(<ProblemMeta problem={problem('Backup')} rootKind="Cluster" />)).toContain('Backup')
|
|
17
|
+
expect(renderToStaticMarkup(<ProblemMeta problem={problem('Cluster')} rootKind="Cluster" />)).not.toContain('child-1')
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('problemOriginLabel', () => {
|
|
22
|
+
it('says who measured a measurement, and never calls an issue generic', () => {
|
|
23
|
+
expect(problemOriginLabel({ ...details('Cluster'), source: 'measurement', measuredBy: 'Prometheus' }).label).toBe('Measured by Prometheus')
|
|
24
|
+
expect(problemOriginLabel(problem('Cluster')).label).toBe('Detected by Radar')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('keeps original scheduler evidence behind a closed disclosure in callouts and lists', () => {
|
|
29
|
+
const p = { ...problem('Pod'), detail: 'both nodes have reached their Pod limit', rawDetailLabel: 'Scheduler message', rawDetail: '0/2 nodes are available: 2 Too many pods.' }
|
|
30
|
+
for (const node of [<WorkspaceProblemCallout problem={p} rootKind="Cluster" />, <ProblemList problems={[p]} rootKind="Cluster" />]) {
|
|
31
|
+
const html = renderToStaticMarkup(node)
|
|
32
|
+
expect(html).toContain(p.detail)
|
|
33
|
+
expect(html).toContain(p.rawDetail)
|
|
34
|
+
expect(html).toContain('Scheduler message')
|
|
35
|
+
expect(html).toContain('aria-expanded="false"')
|
|
36
|
+
}
|
|
37
|
+
expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={problem('Pod')} rootKind="Cluster" />)).not.toContain('Scheduler message')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('uses a generic label for raw evidence from another controller', () => {
|
|
41
|
+
const p = { ...problem('Deployment'), rawDetail: 'Progress deadline exceeded.' }
|
|
42
|
+
expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Application" />)).toContain('Original evidence')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('keeps source-specific provenance fields exclusive in the public type', () => {
|
|
46
|
+
type Details = ReturnType<typeof details>
|
|
47
|
+
expectTypeOf<Details & { source: 'issue'; measuredBy: string }>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
48
|
+
expectTypeOf<Details & { source: 'measurement'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
49
|
+
expectTypeOf<Details & { source: 'audit'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('preserves alert and unknown tones in callouts and compact lists', () => {
|
|
53
|
+
for (const tone of ['alert', 'unknown'] as const) {
|
|
54
|
+
const p = { ...problem('Pod'), tone }
|
|
55
|
+
const callout = renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Cluster" />)
|
|
56
|
+
expect(callout).toContain(`status-${tone}`)
|
|
57
|
+
const list = renderToStaticMarkup(<ProblemList problems={[p]} rootKind="Cluster" />)
|
|
58
|
+
expect(list).toContain(tone === 'alert' ? 'bg-orange-500' : 'bg-slate-400')
|
|
59
|
+
expect(list).toContain(tone === 'alert' ? 'text-orange-600' : 'text-theme-text-tertiary')
|
|
60
|
+
}
|
|
61
|
+
type HealthyProblem = Omit<ReturnType<typeof details>, 'tone'> & { source: 'issue'; tone: 'healthy' }
|
|
62
|
+
expectTypeOf<HealthyProblem>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
63
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import type { HealthLevel } from '../resources/resource-utils'
|
|
4
|
+
import type { ResourceRef } from '../../types/core'
|
|
5
|
+
import { healthToSeverity } from '../../utils/badge-colors'
|
|
6
|
+
import { StatusDot, toneTextClass } from '../ui/status-tone'
|
|
7
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
8
|
+
import { AlertBanner } from '../ui/drawer-components'
|
|
9
|
+
import { FoldSection } from '../ui/FoldSection'
|
|
10
|
+
import { RefLink, type NavigateToRef } from '../ui/RefLink'
|
|
11
|
+
|
|
12
|
+
/** Where a problem's evidence comes from, in user terms. */
|
|
13
|
+
export interface ProblemOrigin {
|
|
14
|
+
label: string
|
|
15
|
+
/** The exact field or condition, shown on hover. */
|
|
16
|
+
detail?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Something about a workspace object that needs a look. `C` is the
|
|
21
|
+
* integration's own category set.
|
|
22
|
+
*/
|
|
23
|
+
export interface ProblemDetails<C extends string = string> {
|
|
24
|
+
/** Stable identity for keys. */
|
|
25
|
+
id: string
|
|
26
|
+
category: C
|
|
27
|
+
title: string
|
|
28
|
+
detail?: string
|
|
29
|
+
rawDetail?: string
|
|
30
|
+
rawDetailLabel?: string
|
|
31
|
+
/** The object the evidence is about: the workspace's root object or one of its children. */
|
|
32
|
+
subject: ResourceRef & { group: string }
|
|
33
|
+
/** A shorter headline for tight places (a fleet cell); `title` stays the precise one. */
|
|
34
|
+
shortTitle?: string
|
|
35
|
+
/** Other objects the same problem is about, e.g. earlier runs that failed the same way. */
|
|
36
|
+
alsoAbout?: Pick<ResourceRef, 'kind' | 'name'>[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ProblemProvenance =
|
|
40
|
+
| { source: 'issue'; origin?: ProblemOrigin; measuredBy?: never; sourceDetail?: never }
|
|
41
|
+
| { source: 'audit'; sourceDetail?: string; origin?: never; measuredBy?: never }
|
|
42
|
+
| { source: 'measurement'; measuredBy?: string; sourceDetail?: string; origin?: never }
|
|
43
|
+
|
|
44
|
+
export type WorkspaceProblem<C extends string = string> = ProblemDetails<C> & ProblemProvenance & { tone: Exclude<HealthLevel, 'healthy'> }
|
|
45
|
+
|
|
46
|
+
/** A problem's provenance label: where its evidence comes from, never a generic "Radar issue". */
|
|
47
|
+
export function problemOriginLabel(problem: WorkspaceProblem): ProblemOrigin {
|
|
48
|
+
switch (problem.source) {
|
|
49
|
+
case 'audit':
|
|
50
|
+
return { label: 'Best-practice check', detail: problem.sourceDetail }
|
|
51
|
+
case 'measurement':
|
|
52
|
+
return { label: problem.measuredBy ? `Measured by ${problem.measuredBy}` : 'Measured', detail: problem.sourceDetail }
|
|
53
|
+
}
|
|
54
|
+
return problem.origin ?? { label: 'Detected by Radar' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How a host opens a problem on its Issues page. Supplied by context so every
|
|
59
|
+
* summary and drawer gets the link without threading a prop through each.
|
|
60
|
+
*/
|
|
61
|
+
export const OpenIssueContext = createContext<((problem: WorkspaceProblem) => void) | undefined>(undefined)
|
|
62
|
+
|
|
63
|
+
export function ProblemMeta({
|
|
64
|
+
problem,
|
|
65
|
+
rootKind,
|
|
66
|
+
onNavigate,
|
|
67
|
+
subjectIsSelf,
|
|
68
|
+
children,
|
|
69
|
+
}: {
|
|
70
|
+
problem: WorkspaceProblem
|
|
71
|
+
/** The workspace's root kind: a problem about another kind names its subject. */
|
|
72
|
+
rootKind: string
|
|
73
|
+
onNavigate?: NavigateToRef
|
|
74
|
+
subjectIsSelf?: boolean
|
|
75
|
+
children?: ReactNode
|
|
76
|
+
}) {
|
|
77
|
+
const openIssue = useContext(OpenIssueContext)
|
|
78
|
+
const origin = problemOriginLabel(problem)
|
|
79
|
+
const aboutChild = !subjectIsSelf && problem.subject.kind !== rootKind
|
|
80
|
+
return (
|
|
81
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
|
|
82
|
+
{aboutChild && (
|
|
83
|
+
<span>
|
|
84
|
+
{problem.subject.kind}{' '}
|
|
85
|
+
<RefLink refTo={problem.subject} onNavigate={onNavigate} mono />
|
|
86
|
+
{problem.alsoAbout && problem.alsoAbout.length > 0 && ' '}
|
|
87
|
+
{problem.alsoAbout && problem.alsoAbout.length > 0 && (
|
|
88
|
+
<Tooltip
|
|
89
|
+
content={
|
|
90
|
+
<ul>
|
|
91
|
+
{problem.alsoAbout.map((o) => (
|
|
92
|
+
<li key={`${o.kind}/${o.name}`} className="font-mono">
|
|
93
|
+
{o.kind} {o.name}
|
|
94
|
+
</li>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
}
|
|
98
|
+
>
|
|
99
|
+
<span>and {problem.alsoAbout.length} more</span>
|
|
100
|
+
</Tooltip>
|
|
101
|
+
)}
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
104
|
+
<Tooltip content={origin.detail} disabled={!origin.detail}>
|
|
105
|
+
<span>{origin.label}</span>
|
|
106
|
+
</Tooltip>
|
|
107
|
+
{openIssue && problem.source === 'issue' && (
|
|
108
|
+
<button type="button" onClick={() => openIssue(problem)} className="whitespace-nowrap text-accent-text hover:underline">
|
|
109
|
+
See in Issues →
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
{children}
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function WorkspaceProblemCallout({
|
|
118
|
+
problem,
|
|
119
|
+
rootKind,
|
|
120
|
+
more,
|
|
121
|
+
onNavigate,
|
|
122
|
+
action,
|
|
123
|
+
subjectIsSelf,
|
|
124
|
+
}: {
|
|
125
|
+
problem: WorkspaceProblem
|
|
126
|
+
rootKind: string
|
|
127
|
+
more?: ReactNode
|
|
128
|
+
onNavigate?: NavigateToRef
|
|
129
|
+
action?: ReactNode
|
|
130
|
+
/** The callout sits on the subject's own page, so linking to it would loop. */
|
|
131
|
+
subjectIsSelf?: boolean
|
|
132
|
+
}) {
|
|
133
|
+
const severity = healthToSeverity(problem.tone)
|
|
134
|
+
const variant = severity === 'neutral' ? 'unknown' : severity
|
|
135
|
+
return (
|
|
136
|
+
<AlertBanner variant={variant} title={problem.title} message={problem.detail}>
|
|
137
|
+
{problem.rawDetail && <FoldSection title={problem.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{problem.rawDetail}</div></FoldSection>}
|
|
138
|
+
<ProblemMeta problem={problem} rootKind={rootKind} onNavigate={onNavigate} subjectIsSelf={subjectIsSelf}>
|
|
139
|
+
{action}
|
|
140
|
+
{more}
|
|
141
|
+
</ProblemMeta>
|
|
142
|
+
</AlertBanner>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The problems a callout does not show, as a compact list with the callout's tone, title and source. */
|
|
147
|
+
export function ProblemList({ problems, rootKind, onNavigate }: { problems: WorkspaceProblem[]; rootKind: string; onNavigate?: NavigateToRef }) {
|
|
148
|
+
return (
|
|
149
|
+
<ul className="space-y-2">
|
|
150
|
+
{problems.map((p) => (
|
|
151
|
+
<li key={p.id} className="flex items-start gap-2 text-sm">
|
|
152
|
+
<span className="mt-1.5 shrink-0">
|
|
153
|
+
<StatusDot tone={p.tone} size="sm" />
|
|
154
|
+
</span>
|
|
155
|
+
<div className="min-w-0">
|
|
156
|
+
<div className={clsx('font-medium break-words', toneTextClass(p.tone))}>{p.title}</div>
|
|
157
|
+
{p.detail && <div className="text-xs text-theme-text-secondary break-words">{p.detail}</div>}
|
|
158
|
+
{p.rawDetail && <FoldSection title={p.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{p.rawDetail}</div></FoldSection>}
|
|
159
|
+
<ProblemMeta problem={p} rootKind={rootKind} onNavigate={onNavigate} />
|
|
160
|
+
</div>
|
|
161
|
+
</li>
|
|
162
|
+
))}
|
|
163
|
+
</ul>
|
|
164
|
+
)
|
|
165
|
+
}
|