@skyhook-io/radar-app 1.14.6 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +6 -5
- package/src/App.tsx +65 -17
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +269 -204
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +190 -0
- package/src/api/radarFeatures.test.ts +133 -0
- package/src/api/radarFeatures.ts +147 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +95 -0
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +7 -3
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +393 -571
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +21 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +17 -1
- package/src/components/ui/UpdateNotification.tsx +70 -21
- package/src/components/ui/command-items.ts +14 -0
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.tsx +47 -14
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- package/src/index.ts +1 -0
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- package/src/utils/version.ts +11 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
|
|
7
|
+
// scripts/check-whats-new.sh gates every minor release on having notes; these
|
|
8
|
+
// pin what counts as an entry, against catalogs written to a temp dir.
|
|
9
|
+
const script = join(__dirname, '..', '..', '..', '..', 'scripts', 'check-whats-new.sh')
|
|
10
|
+
|
|
11
|
+
function check(version: string, catalog: string) {
|
|
12
|
+
const file = join(mkdtempSync(join(tmpdir(), 'whats-new-')), 'releaseNotes.ts')
|
|
13
|
+
writeFileSync(file, catalog)
|
|
14
|
+
return spawnSync('bash', [script, version], { env: { ...process.env, WHATS_NEW_CATALOG: file }, encoding: 'utf8' }).status
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const empty = `export const RELEASE_NOTES: ReleaseNotes[] = []\n`
|
|
18
|
+
const withEntry = (entry: string, before = '') => `${before}export const RELEASE_NOTES: ReleaseNotes[] = [\n${entry}\n]\n\nexport function after() {}\n`
|
|
19
|
+
|
|
20
|
+
describe('check-whats-new.sh', () => {
|
|
21
|
+
it('lets patches and prereleases through without notes', () => {
|
|
22
|
+
expect(check('v1.15.1', empty)).toBe(0)
|
|
23
|
+
expect(check('v1.15.0-rc.1', empty)).toBe(0)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('refuses a minor or major release without notes, including one with build metadata', () => {
|
|
27
|
+
expect(check('v1.15.0', empty)).toBe(1)
|
|
28
|
+
expect(check('v2.0.0', empty)).toBe(1)
|
|
29
|
+
expect(check('v1.15.0+build.1', empty)).toBe(1)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('refuses a version that is not vX.Y.Z', () => {
|
|
33
|
+
expect(check('v1.15', empty)).toBe(1)
|
|
34
|
+
expect(check('latest', empty)).toBe(1)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('accepts a real entry, wherever version sits in it', () => {
|
|
38
|
+
const entry = ` {\n releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.15.0', version: 'v1.15.0',\n highlights: [],\n },`
|
|
39
|
+
expect(check('v1.15.0', withEntry(entry))).toBe(0)
|
|
40
|
+
expect(check('1.15.0+build.1', withEntry(entry))).toBe(0)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('does not count comments, objects outside the catalog, or look-alike properties', () => {
|
|
44
|
+
const decoys = `// { version: 'v1.16.0' },\n/* { version: 'v1.17.0' } */\nconst draft = { version: 'v1.18.0' }\n`
|
|
45
|
+
const catalog = withEntry(` // { version: 'v1.20.0' },\n { notversion: 'v1.19.0' },`, decoys)
|
|
46
|
+
for (const v of ['v1.16.0', 'v1.17.0', 'v1.18.0', 'v1.19.0', 'v1.20.0']) expect(check(v, catalog), v).toBe(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('ignores an object after an empty catalog, even with a semicolon', () => {
|
|
50
|
+
expect(check('v1.15.0', `export const RELEASE_NOTES: ReleaseNotes[] = [];\nconst draft = { version: 'v1.15.0' };\n`)).toBe(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('does not count the words "version:" inside another entry\'s text', () => {
|
|
54
|
+
const entry = ` {\n version: 'v1.14.0',\n improvements: ["Set version: 'v1.15.0' in your configuration"],\n },`
|
|
55
|
+
expect(check('v1.15.0', withEntry(entry))).toBe(1)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('accepts a value on the line after its key, quoted keys, and space before the colon', () => {
|
|
59
|
+
expect(check('v1.15.0', withEntry(` {\n version:\n 'v1.15.0',\n },`))).toBe(0)
|
|
60
|
+
expect(check('v1.15.0', withEntry(` { version: "v1.15.0" },`))).toBe(0)
|
|
61
|
+
expect(check('v1.15.0', withEntry(` { "version": "v1.15.0" },`))).toBe(0)
|
|
62
|
+
expect(check('v1.15.0', withEntry(` { version : 'v1.15.0' },`))).toBe(0)
|
|
63
|
+
expect(check('v1.15.0', withEntry(` {\n "version" :\n "v1.15.0",\n },`))).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react'
|
|
2
|
+
import { Bot, GitCompareArrows, Layers, Network, Scale } from 'lucide-react'
|
|
3
|
+
import { compareVersions } from '../../utils/version'
|
|
4
|
+
|
|
5
|
+
export interface ReleaseHighlight {
|
|
6
|
+
id: string
|
|
7
|
+
icon: LucideIcon
|
|
8
|
+
title: string
|
|
9
|
+
description: string
|
|
10
|
+
/** In-app route the highlight's call to action opens. */
|
|
11
|
+
path?: string
|
|
12
|
+
cta?: string
|
|
13
|
+
/** Icon tile color, so the cards read apart at a glance. The lead card always uses the accent. */
|
|
14
|
+
tone?: HighlightTone
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type HighlightTone = 'violet' | 'teal' | 'emerald' | 'indigo'
|
|
18
|
+
|
|
19
|
+
export interface ReleaseNotes {
|
|
20
|
+
version: string
|
|
21
|
+
/** The first highlight leads the dialog at full width. */
|
|
22
|
+
highlights: ReleaseHighlight[]
|
|
23
|
+
improvements: string[]
|
|
24
|
+
releaseUrl: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One entry per release that has something to announce. A version without its
|
|
28
|
+
// own entry shows the newest entry at or below it, so a patch upgrade (or one
|
|
29
|
+
// that skips the release with notes) still gets them. Every minor and major
|
|
30
|
+
// release must have its own entry: scripts/check-whats-new.sh refuses to
|
|
31
|
+
// release one without it.
|
|
32
|
+
export const RELEASE_NOTES: ReleaseNotes[] = [
|
|
33
|
+
{
|
|
34
|
+
version: 'v1.15.0',
|
|
35
|
+
releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.15.0',
|
|
36
|
+
highlights: [
|
|
37
|
+
{
|
|
38
|
+
id: 'ai-investigations',
|
|
39
|
+
icon: Bot,
|
|
40
|
+
title: 'AI investigations that show their work',
|
|
41
|
+
// No link: the investigations workspace redirects home when this run
|
|
42
|
+
// mode can't host local agents.
|
|
43
|
+
description: 'A clear verdict and the story of what broke, with the charts, logs and config behind it placed where the agent cites them. Now also runs on OpenCode, including AWS Bedrock.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'gitops-manifest-diff',
|
|
47
|
+
icon: GitCompareArrows,
|
|
48
|
+
title: 'Full manifest diffs for Argo CD resources',
|
|
49
|
+
description: 'Compare the Git-rendered and live manifest of a drifted Argo CD resource, side by side or unified.',
|
|
50
|
+
tone: 'violet',
|
|
51
|
+
path: '/gitops',
|
|
52
|
+
cta: 'Open GitOps',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'rightsizing-risks-first',
|
|
56
|
+
icon: Scale,
|
|
57
|
+
title: 'Rightsizing and cost data in MCP',
|
|
58
|
+
description: 'Agents get new optimized tools. Risks (OOM, limit conflicts and throttling) lead before cost-saving, in the UI as well.',
|
|
59
|
+
tone: 'emerald',
|
|
60
|
+
path: '/cost/rightsizing',
|
|
61
|
+
cta: 'Open Rightsizing',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'batch-ai-workloads',
|
|
65
|
+
icon: Layers,
|
|
66
|
+
title: 'Deeper batch + AI/ML workloads support',
|
|
67
|
+
description: 'Dedicated pages for Kueue, JobSet and Ray resources, and Job and JobSet details now show why a queued job has no Pods.',
|
|
68
|
+
tone: 'teal',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'rollout-traffic-topology',
|
|
72
|
+
icon: Network,
|
|
73
|
+
title: 'Canary and blue-green in Topology',
|
|
74
|
+
description: 'See canary and stable Pods, which Services route to each side, and the canary traffic weight.',
|
|
75
|
+
tone: 'indigo',
|
|
76
|
+
path: '/topology',
|
|
77
|
+
cta: 'Open Topology',
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
improvements: [
|
|
81
|
+
// The grid fills row by row: pair lines of similar length.
|
|
82
|
+
'Resource tables remember your sort for each kind',
|
|
83
|
+
'Install Helm charts from OCI registries',
|
|
84
|
+
'Checks flag expired and expiring TLS certificates',
|
|
85
|
+
'Radar Desktop keeps your preferences across restarts',
|
|
86
|
+
'Issues show where a failing Pod differs from its template',
|
|
87
|
+
'PVC details say why usage data is unavailable',
|
|
88
|
+
'Issues catch Strimzi connector and admission webhook failures',
|
|
89
|
+
'Bind the web UI to a specific IP with --listen-address',
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
export function releaseNotesFor(
|
|
95
|
+
version: string | undefined,
|
|
96
|
+
catalog: ReleaseNotes[] = RELEASE_NOTES,
|
|
97
|
+
): ReleaseNotes | undefined {
|
|
98
|
+
if (!version) return undefined
|
|
99
|
+
const normalized = version.startsWith('v') ? version : `v${version}`
|
|
100
|
+
return catalog.find(notes => notes.version === normalized)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The release line a catalog entry covers, e.g. v1.15 for v1.15.0: its patches show the same notes. */
|
|
104
|
+
export function releaseLine(version: string): string {
|
|
105
|
+
return version.replace(/^(v\d+\.\d+)\.\d+$/, '$1')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The newest notes for a release at or below `version` — what that version's user has access to. */
|
|
109
|
+
export function latestReleaseNotesFor(
|
|
110
|
+
version: string | undefined,
|
|
111
|
+
catalog: ReleaseNotes[] = RELEASE_NOTES,
|
|
112
|
+
): ReleaseNotes | undefined {
|
|
113
|
+
if (!version) return undefined
|
|
114
|
+
let latest: ReleaseNotes | undefined
|
|
115
|
+
for (const notes of catalog) {
|
|
116
|
+
const atOrBelow = compareVersions(notes.version, version)
|
|
117
|
+
if (atOrBelow === null || atOrBelow > 0) continue
|
|
118
|
+
if (!latest || (compareVersions(notes.version, latest.version) ?? 0) > 0) latest = notes
|
|
119
|
+
}
|
|
120
|
+
return latest
|
|
121
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { RayJobRenderer } from '../resources/renderers/RayJobRenderer'
|
|
2
|
+
import { JobRenderer, JobSetRenderer } from '../resources/renderers/JobAdmissionRenderers'
|
|
1
3
|
import { RayClusterRenderer } from '../resources/renderers/RayClusterRenderer'
|
|
2
4
|
import { RayServiceRenderer } from '../resources/renderers/RayServiceRenderer'
|
|
3
5
|
import { KueueWorkloadRenderer } from '../resources/renderers/KueueWorkloadRenderer'
|
|
@@ -34,6 +36,7 @@ import {
|
|
|
34
36
|
import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
|
|
35
37
|
import { isJobSetV1Alpha2 } from '@skyhook-io/k8s-ui/components/resources/resource-utils-jobset-lws'
|
|
36
38
|
import type { SelectedResource, ResourceRef, Relationships, ResourceWithRelationships } from '../../types'
|
|
39
|
+
import { useHistoryPaging } from './historyPaging'
|
|
37
40
|
import {
|
|
38
41
|
kindToPlural,
|
|
39
42
|
kindToPluralWithGroup,
|
|
@@ -44,7 +47,8 @@ import {
|
|
|
44
47
|
type NavigateToResource,
|
|
45
48
|
} from '../../utils/navigation'
|
|
46
49
|
import {
|
|
47
|
-
|
|
50
|
+
useWorkloadHistory,
|
|
51
|
+
fetchWorkloadHistoryPage,
|
|
48
52
|
useResourceWithRelationships,
|
|
49
53
|
usePodLogs,
|
|
50
54
|
useTopology,
|
|
@@ -75,7 +79,6 @@ import {
|
|
|
75
79
|
useUncordonNode,
|
|
76
80
|
useDrainNode,
|
|
77
81
|
useDrainPlan,
|
|
78
|
-
DrainPlanUnsupportedError,
|
|
79
82
|
useCascadeDeletePreview,
|
|
80
83
|
useResourceEvents,
|
|
81
84
|
useResource,
|
|
@@ -90,8 +93,9 @@ import { RestartEventLane } from '../resource/RestartChart'
|
|
|
90
93
|
import { RightsizingPanel } from '../resource/RightsizingStrip'
|
|
91
94
|
import { WorkloadCostTab } from '../cost/WorkloadCostTab'
|
|
92
95
|
import { isOpenCostWorkloadKind } from '../cost/kinds'
|
|
96
|
+
import { isRadarFeatureUnsupported } from '../../api/radarFeatures'
|
|
93
97
|
import { useResourceAudit, useResourceIssues, useResources, useTrace, fetchTraceWithProbes, fetchInClusterCapability, runInClusterMerged } from '../../api/client'
|
|
94
|
-
import { AuditAlerts, ResourceIssuesSection, ReachabilityView, TraceSummary, InClusterConsentDialog, traceFingerprint, staticPollUnreliable, summarizeInClusterTests, type Trace as NetworkTrace, type InClusterCapability, inClusterConsentGiven, consentRequestRows } from '@skyhook-io/k8s-ui'
|
|
98
|
+
import { AuditAlerts, getRadarUpgradeRequirement, ResourceIssuesSection, ReachabilityView, TraceSummary, InClusterConsentDialog, traceFingerprint, staticPollUnreliable, summarizeInClusterTests, type Trace as NetworkTrace, type InClusterCapability, inClusterConsentGiven, consentRequestRows } from '@skyhook-io/k8s-ui'
|
|
95
99
|
import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
|
|
96
100
|
import { ScheduledWorkloadLogsViewer } from '../logs/ScheduledWorkloadLogsViewer'
|
|
97
101
|
import { LogsViewer } from '../logs/LogsViewer'
|
|
@@ -171,6 +175,9 @@ export function supportsBatchExecution(kind: string, apiKind: string, group?: st
|
|
|
171
175
|
|
|
172
176
|
// Stable reference — web renderer wrappers inject platform hooks internally
|
|
173
177
|
const rendererOverrides: RendererOverrides = {
|
|
178
|
+
RayJobRenderer,
|
|
179
|
+
JobRenderer,
|
|
180
|
+
JobSetRenderer,
|
|
174
181
|
RayServiceRenderer,
|
|
175
182
|
RayClusterRenderer,
|
|
176
183
|
KueueWorkloadRenderer,
|
|
@@ -513,7 +520,9 @@ function useActionsBarProps(
|
|
|
513
520
|
drainPlan: drainPlanMutation.data ?? null,
|
|
514
521
|
isPlanningDrain: drainPlanMutation.isPending,
|
|
515
522
|
drainPlanError: drainPlanMutation.error?.message ?? null,
|
|
516
|
-
|
|
523
|
+
drainPlanUpgrade: isRadarFeatureUnsupported(drainPlanMutation.error, 'drainPlan')
|
|
524
|
+
? getRadarUpgradeRequirement(drainPlanMutation.error)
|
|
525
|
+
: null,
|
|
517
526
|
}
|
|
518
527
|
}
|
|
519
528
|
|
|
@@ -607,6 +616,11 @@ export function WorkloadView({
|
|
|
607
616
|
const refetchResourceAndRuns = useCallback(async () => {
|
|
608
617
|
await Promise.all([
|
|
609
618
|
refetchResource(),
|
|
619
|
+
...(apiKind === 'rayjobs' && effectiveGroup === 'ray.io' ? [
|
|
620
|
+
queryClient.refetchQueries({ queryKey: ['resource', 'jobs', namespace, name, 'batch'], type: 'active' }),
|
|
621
|
+
...(resource?.spec?.clusterSelector?.['ray.io/cluster'] ? [queryClient.refetchQueries({ queryKey: ['resource', 'rayclusters', namespace, resource.spec.clusterSelector['ray.io/cluster'], 'ray.io'], type: 'active' })] : []),
|
|
622
|
+
] : []),
|
|
623
|
+
queryClient.refetchQueries({ queryKey: ['kueue-admission', effectiveGroup, apiKind, namespace, name], type: 'active' }),
|
|
610
624
|
queryClient.refetchQueries({
|
|
611
625
|
queryKey: ['workload-runs', apiKind, namespace, name],
|
|
612
626
|
}),
|
|
@@ -615,7 +629,7 @@ export function WorkloadView({
|
|
|
615
629
|
...(apiKind === 'rayclusters' ? { type: 'active' as const } : {}),
|
|
616
630
|
}),
|
|
617
631
|
])
|
|
618
|
-
}, [apiKind, name, namespace, queryClient, refetchResource])
|
|
632
|
+
}, [apiKind, effectiveGroup, name, namespace, queryClient, refetchResource, resource?.spec?.clusterSelector])
|
|
619
633
|
const podWorkloadOwner = useMemo(
|
|
620
634
|
() => podWorkloadOwnerFromRelationships(apiKind, namespace, relationships, resource),
|
|
621
635
|
[apiKind, namespace, relationships, resource],
|
|
@@ -781,15 +795,26 @@ export function WorkloadView({
|
|
|
781
795
|
updatesError: resourceFocusedUpdatesError,
|
|
782
796
|
} = useResourceEvents(apiKind, namespace, name, effectiveGroup)
|
|
783
797
|
|
|
784
|
-
//
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
798
|
+
// This workload's history (only when expanded): the workload, what it owns,
|
|
799
|
+
// K8s Events about those, and the resources attached to it. Older pages load
|
|
800
|
+
// on demand and stay until the viewed workload changes.
|
|
801
|
+
// The group settles from the route, or from the fetched resource when the
|
|
802
|
+
// route has none; asking before then would key the history (and resolve a
|
|
803
|
+
// colliding kind) under the wrong group and refetch once it settles.
|
|
804
|
+
const historyGroupSettled = Boolean(rest.group) || resource !== undefined || resourceError != null
|
|
805
|
+
const historyQuery = useWorkloadHistory(apiKind, namespace, name, effectiveGroup, expanded && historyGroupSettled)
|
|
806
|
+
const {
|
|
807
|
+
events: allEvents,
|
|
808
|
+
truncated: historyTruncated,
|
|
809
|
+
loadOlder: loadOlderHistory,
|
|
810
|
+
loadingOlder: loadingOlderHistory,
|
|
811
|
+
olderError: olderHistoryError,
|
|
812
|
+
} = useHistoryPaging(
|
|
813
|
+
`${effectiveGroup ?? ''}/${apiKind}/${namespace}/${name}`,
|
|
814
|
+
historyQuery.data,
|
|
815
|
+
(beforeSeq) => fetchWorkloadHistoryPage(apiKind, namespace, name, effectiveGroup, beforeSeq),
|
|
816
|
+
)
|
|
817
|
+
const eventsLoading = historyQuery.isLoading || (expanded && !historyGroupSettled)
|
|
793
818
|
|
|
794
819
|
// RBAC
|
|
795
820
|
const canUpdateSecrets = useCanUpdateSecrets()
|
|
@@ -1186,6 +1211,14 @@ export function WorkloadView({
|
|
|
1186
1211
|
refetch={refetchResourceAndRuns}
|
|
1187
1212
|
// Timeline
|
|
1188
1213
|
allEvents={allEvents}
|
|
1214
|
+
historyTruncated={historyTruncated}
|
|
1215
|
+
onLoadOlderHistory={loadOlderHistory}
|
|
1216
|
+
loadingOlderHistory={loadingOlderHistory}
|
|
1217
|
+
olderHistoryError={olderHistoryError}
|
|
1218
|
+
historyScoped
|
|
1219
|
+
historyIncomplete={Boolean(historyQuery.data?.incomplete)}
|
|
1220
|
+
historyError={historyQuery.error as Error | null}
|
|
1221
|
+
onRetryHistory={() => void historyQuery.refetch()}
|
|
1189
1222
|
relatedTimelineEvents={relatedTimelineEvents}
|
|
1190
1223
|
eventsLoading={eventsLoading || (batchExecution && batchKind !== 'JobSet' && batchRunsQuery.isLoading)}
|
|
1191
1224
|
topology={topology}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { WorkloadHistoryPage } from '../../api/client'
|
|
3
|
+
import type { TimelineEvent } from '../../types'
|
|
4
|
+
import { addOlderPage, foldRefreshedPage } from './historyPaging'
|
|
5
|
+
|
|
6
|
+
// Rows newest first, each id its own seq.
|
|
7
|
+
const ev = (id: string, seq: number) => ({ id, seq }) as TimelineEvent
|
|
8
|
+
const rows = (from: number, to: number) => Array.from({ length: from - to + 1 }, (_, i) => ev(String(from - i), from - i))
|
|
9
|
+
const page = (from: number, to: number, truncated: boolean): WorkloadHistoryPage => ({
|
|
10
|
+
events: rows(from, to),
|
|
11
|
+
truncated,
|
|
12
|
+
nextBeforeSeq: truncated ? to : undefined,
|
|
13
|
+
})
|
|
14
|
+
const ids = (p: WorkloadHistoryPage) => p.events.map((e) => Number(e.id))
|
|
15
|
+
const contiguous = (p: WorkloadHistoryPage, from: number, to: number) =>
|
|
16
|
+
expect(new Set(ids(p))).toEqual(new Set(rows(from, to).map((e) => Number(e.id))))
|
|
17
|
+
|
|
18
|
+
describe('workload history paging', () => {
|
|
19
|
+
it('keeps rows the newest page slides past once older pages are loaded', () => {
|
|
20
|
+
const first = page(40, 21, true)
|
|
21
|
+
const step = addOlderPage(null, page(20, 1, false), first, first)
|
|
22
|
+
if (step.kind !== 'set') throw new Error(step.kind)
|
|
23
|
+
let loaded = step.history
|
|
24
|
+
for (const refreshed of [page(50, 31, true), page(60, 41, true)]) {
|
|
25
|
+
const next = foldRefreshedPage(loaded, refreshed)
|
|
26
|
+
if (next.kind === 'set') loaded = next.history
|
|
27
|
+
else expect(next.kind).toBe('keep')
|
|
28
|
+
}
|
|
29
|
+
contiguous(loaded, 60, 1)
|
|
30
|
+
expect(loaded.truncated).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('restarts paging when a refresh no longer reaches what is loaded', () => {
|
|
34
|
+
const loaded = page(40, 1, false)
|
|
35
|
+
expect(foldRefreshedPage(loaded, page(80, 61, true)).kind).toBe('restart')
|
|
36
|
+
expect(foldRefreshedPage(loaded, page(45, 26, true)).kind).toBe('set')
|
|
37
|
+
expect(foldRefreshedPage(loaded, page(40, 21, true)).kind).toBe('keep')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('anchors a first older page to what refreshed while it loaded', () => {
|
|
41
|
+
const base = page(40, 21, true)
|
|
42
|
+
const step = addOlderPage(null, page(20, 1, false), base, page(45, 26, true))
|
|
43
|
+
if (step.kind !== 'set') throw new Error(step.kind)
|
|
44
|
+
contiguous(step.history, 45, 1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('restarts instead of landing a first older page behind a hole', () => {
|
|
48
|
+
const base = page(40, 21, true)
|
|
49
|
+
expect(addOlderPage(null, page(20, 1, false), base, page(90, 71, true)).kind).toBe('restart')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('a repeated K8s Event must not hide a gap after Load older', () => {
|
|
53
|
+
// Loaded after Load older: events 120..1. "backoff" is a K8s Event already held, at seq 50.
|
|
54
|
+
const loaded: WorkloadHistoryPage = { events: [...rows(120, 51), ev('backoff', 50), ...rows(49, 1)], truncated: false }
|
|
55
|
+
// 200 arrive (121..320) and the page holds 20; "backoff" fired again: same id, seq 320.
|
|
56
|
+
const refreshed: WorkloadHistoryPage = { events: [ev('backoff', 320), ...rows(319, 301)], truncated: true, nextBeforeSeq: 301 }
|
|
57
|
+
expect(foldRefreshedPage(loaded, refreshed).kind).toBe('restart')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('handles a loaded history far larger than a page', () => {
|
|
61
|
+
const loaded: WorkloadHistoryPage = { events: rows(200_000, 1), truncated: false }
|
|
62
|
+
expect(foldRefreshedPage(loaded, page(200_020, 200_001, true)).kind).toBe('restart')
|
|
63
|
+
expect(foldRefreshedPage(loaded, page(200_010, 199_991, true)).kind).toBe('set')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("keeps a repeated K8s Event's newer copy", () => {
|
|
67
|
+
const loaded: WorkloadHistoryPage = { events: [...rows(30, 21), ev('backoff', 20), ...rows(19, 1)], truncated: false }
|
|
68
|
+
const step = foldRefreshedPage(loaded, { events: [ev('backoff', 31), ...rows(30, 22)], truncated: true, nextBeforeSeq: 22 })
|
|
69
|
+
if (step.kind !== 'set') throw new Error(step.kind)
|
|
70
|
+
expect(step.history.events.filter((e) => e.id === 'backoff').map((e) => e.seq)).toEqual([31])
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { WorkloadHistoryPage } from '../../api/client'
|
|
3
|
+
import type { TimelineEvent } from '../../types'
|
|
4
|
+
|
|
5
|
+
// Paging state for a workload's history: the newest page keeps refreshing on
|
|
6
|
+
// its own, and older pages load on demand below it. These rules keep the two
|
|
7
|
+
// contiguous. When they can't (more arrived between refreshes than one page
|
|
8
|
+
// holds), paging restarts from the newest page instead of leaving a hole.
|
|
9
|
+
//
|
|
10
|
+
// Continuity is judged by seq, not by id: a K8s Event that fires again keeps
|
|
11
|
+
// its id but moves to a new, higher seq, so a page can share an id with what's
|
|
12
|
+
// loaded and still not reach it.
|
|
13
|
+
|
|
14
|
+
export type PagingStep = { kind: 'keep' } | { kind: 'restart' } | { kind: 'set'; history: WorkloadHistoryPage }
|
|
15
|
+
|
|
16
|
+
const seqOf = (e: TimelineEvent) => e.seq ?? 0
|
|
17
|
+
|
|
18
|
+
/** Whether a newest page reaches down to rows already loaded, leaving no gap. */
|
|
19
|
+
function reaches(page: WorkloadHistoryPage, loaded: TimelineEvent[]): boolean {
|
|
20
|
+
if (!page.truncated) return true
|
|
21
|
+
if (loaded.length === 0 || page.events.length === 0) return false
|
|
22
|
+
// Loops, not Math.max(...rows): loaded history has no cap, and spreading it
|
|
23
|
+
// into arguments overflows the stack.
|
|
24
|
+
let newestLoaded = -Infinity
|
|
25
|
+
for (const e of loaded) newestLoaded = Math.max(newestLoaded, seqOf(e))
|
|
26
|
+
let oldestOnPage = Infinity
|
|
27
|
+
for (const e of page.events) oldestOnPage = Math.min(oldestOnPage, seqOf(e))
|
|
28
|
+
return oldestOnPage <= newestLoaded
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Newer rows first; a row both lists hold keeps the newer copy (a repeated
|
|
32
|
+
* K8s Event's count and message move with it). */
|
|
33
|
+
function mergeNewer(newer: TimelineEvent[], older: TimelineEvent[]): TimelineEvent[] {
|
|
34
|
+
const ids = new Set(newer.map((e) => e.id))
|
|
35
|
+
return [...newer, ...older.filter((e) => !ids.has(e.id))]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Folds a refreshed newest page into the loaded older pages. */
|
|
39
|
+
export function foldRefreshedPage(loaded: WorkloadHistoryPage, page: WorkloadHistoryPage): PagingStep {
|
|
40
|
+
const seqById = new Map(loaded.events.map((e) => [e.id, seqOf(e)]))
|
|
41
|
+
if (page.events.every((e) => seqById.get(e.id) === seqOf(e))) return { kind: 'keep' }
|
|
42
|
+
if (!reaches(page, loaded.events)) return { kind: 'restart' }
|
|
43
|
+
return { kind: 'set', history: { ...loaded, events: mergeNewer(page.events, loaded.events) } }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Adds an older page. The first one continues from the newest page it was
|
|
48
|
+
* requested from (basePage), and keeps that page plus whatever refreshed
|
|
49
|
+
* since (latest), unless the refresh has already moved past it.
|
|
50
|
+
*/
|
|
51
|
+
export function addOlderPage(
|
|
52
|
+
loaded: WorkloadHistoryPage | null,
|
|
53
|
+
page: WorkloadHistoryPage,
|
|
54
|
+
basePage: WorkloadHistoryPage | undefined,
|
|
55
|
+
latest: WorkloadHistoryPage | undefined,
|
|
56
|
+
): PagingStep {
|
|
57
|
+
const more = { truncated: page.truncated, nextBeforeSeq: page.nextBeforeSeq }
|
|
58
|
+
if (loaded) return { kind: 'set', history: { events: mergeNewer(loaded.events, page.events), ...more } }
|
|
59
|
+
let newest = basePage?.events ?? []
|
|
60
|
+
if (latest && latest !== basePage) {
|
|
61
|
+
if (!reaches(latest, newest)) return { kind: 'restart' }
|
|
62
|
+
newest = mergeNewer(latest.events, newest)
|
|
63
|
+
}
|
|
64
|
+
return { kind: 'set', history: { events: mergeNewer(newest, page.events), ...more } }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A workload's history: the newest page (refreshed by its query) plus older
|
|
69
|
+
* pages loaded on demand. identity names the workload; changing it restarts
|
|
70
|
+
* paging, and a page requested before a restart is dropped when it arrives.
|
|
71
|
+
*/
|
|
72
|
+
export function useHistoryPaging(
|
|
73
|
+
identity: string,
|
|
74
|
+
newest: WorkloadHistoryPage | undefined,
|
|
75
|
+
fetchOlder: (beforeSeq: number) => Promise<WorkloadHistoryPage>,
|
|
76
|
+
) {
|
|
77
|
+
// Loaded pages carry the workload they belong to, so the render between a
|
|
78
|
+
// navigation and the restart below never shows another workload's rows.
|
|
79
|
+
const [olderState, setOlderState] = useState<{ identity: string; history: WorkloadHistoryPage } | null>(null)
|
|
80
|
+
const older = olderState?.identity === identity ? olderState.history : null
|
|
81
|
+
const setOlder = useCallback(
|
|
82
|
+
(history: WorkloadHistoryPage | null) => setOlderState(history ? { identity, history } : null),
|
|
83
|
+
[identity],
|
|
84
|
+
)
|
|
85
|
+
const [loadingOlder, setLoadingOlder] = useState(false)
|
|
86
|
+
const [olderError, setOlderError] = useState<Error | null>(null)
|
|
87
|
+
const generation = useRef(0)
|
|
88
|
+
const latest = useRef(newest)
|
|
89
|
+
latest.current = newest
|
|
90
|
+
const loaded = useRef(older)
|
|
91
|
+
loaded.current = older
|
|
92
|
+
|
|
93
|
+
const restart = useCallback(() => {
|
|
94
|
+
generation.current++
|
|
95
|
+
// Effects later in this commit (the refresh fold below) read the ref
|
|
96
|
+
// before the next render would update it.
|
|
97
|
+
loaded.current = null
|
|
98
|
+
setOlderState(null)
|
|
99
|
+
setOlderError(null)
|
|
100
|
+
setLoadingOlder(false)
|
|
101
|
+
}, [])
|
|
102
|
+
useEffect(restart, [identity, restart])
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!newest || !loaded.current) return
|
|
106
|
+
const step = foldRefreshedPage(loaded.current, newest)
|
|
107
|
+
if (step.kind === 'restart') restart()
|
|
108
|
+
else if (step.kind === 'set') setOlder(step.history)
|
|
109
|
+
}, [newest, restart, setOlder])
|
|
110
|
+
|
|
111
|
+
const events = useMemo<TimelineEvent[] | undefined>(() => {
|
|
112
|
+
if (!newest) return undefined
|
|
113
|
+
if (!older) return newest.events
|
|
114
|
+
const byId = new Map(newest.events.map((e) => [e.id, e]))
|
|
115
|
+
for (const e of older.events) if (!byId.has(e.id)) byId.set(e.id, e)
|
|
116
|
+
return [...byId.values()]
|
|
117
|
+
}, [newest, older])
|
|
118
|
+
|
|
119
|
+
const loadOlder = useCallback(async () => {
|
|
120
|
+
const basePage = older ? undefined : newest
|
|
121
|
+
const cursor = older ? older.nextBeforeSeq : basePage?.nextBeforeSeq
|
|
122
|
+
if (!cursor) return
|
|
123
|
+
const requested = generation.current
|
|
124
|
+
setLoadingOlder(true)
|
|
125
|
+
setOlderError(null)
|
|
126
|
+
try {
|
|
127
|
+
const page = await fetchOlder(cursor)
|
|
128
|
+
if (generation.current !== requested) return
|
|
129
|
+
const step = addOlderPage(loaded.current, page, basePage, latest.current)
|
|
130
|
+
if (step.kind === 'restart') restart()
|
|
131
|
+
else if (step.kind === 'set') setOlder(step.history)
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (generation.current !== requested) return
|
|
134
|
+
setOlderError(err instanceof Error ? err : new Error(String(err)))
|
|
135
|
+
} finally {
|
|
136
|
+
if (generation.current === requested) setLoadingOlder(false)
|
|
137
|
+
}
|
|
138
|
+
}, [older, newest, fetchOlder, restart, setOlder])
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
events,
|
|
142
|
+
truncated: older ? older.truncated : Boolean(newest?.truncated),
|
|
143
|
+
loadOlder,
|
|
144
|
+
loadingOlder,
|
|
145
|
+
olderError,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import type { WorkloadHistoryPage } from '../../api/client'
|
|
6
|
+
import type { TimelineEvent } from '../../types'
|
|
7
|
+
import { useHistoryPaging } from './historyPaging'
|
|
8
|
+
|
|
9
|
+
const page = (prefix: string, from: number, to: number, truncated: boolean): WorkloadHistoryPage => ({
|
|
10
|
+
events: Array.from({ length: from - to + 1 }, (_, i) => ({ id: `${prefix}${from - i}`, seq: from - i }) as TimelineEvent),
|
|
11
|
+
truncated,
|
|
12
|
+
nextBeforeSeq: truncated ? to : undefined,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
type Paging = ReturnType<typeof useHistoryPaging>
|
|
16
|
+
const renders: Paging[] = []
|
|
17
|
+
function Harness(props: { identity: string; newest?: WorkloadHistoryPage; fetchOlder: (seq: number) => Promise<WorkloadHistoryPage> }) {
|
|
18
|
+
renders.push(useHistoryPaging(props.identity, props.newest, props.fetchOlder))
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
const latest = () => renders[renders.length - 1]
|
|
22
|
+
|
|
23
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
24
|
+
let root: Root
|
|
25
|
+
let element: HTMLDivElement
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
renders.length = 0
|
|
28
|
+
element = document.createElement('div')
|
|
29
|
+
root = createRoot(element)
|
|
30
|
+
})
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await act(async () => root.unmount())
|
|
33
|
+
})
|
|
34
|
+
const render = (props: Parameters<typeof Harness>[0]) => act(async () => root.render(<Harness {...props} />))
|
|
35
|
+
const ids = () => (latest().events ?? []).map((e) => e.id).sort()
|
|
36
|
+
function deferred() {
|
|
37
|
+
let resolve!: (p: WorkloadHistoryPage) => void
|
|
38
|
+
const promise = new Promise<WorkloadHistoryPage>((r) => (resolve = r))
|
|
39
|
+
return { promise, resolve }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('useHistoryPaging', () => {
|
|
43
|
+
it("shows only the new workload's history after navigating away from loaded older pages", async () => {
|
|
44
|
+
const fetchOlder = async () => page('a', 2, 1, false)
|
|
45
|
+
await render({ identity: 'A', newest: page('a', 4, 3, true), fetchOlder })
|
|
46
|
+
await act(async () => latest().loadOlder())
|
|
47
|
+
expect(ids()).toEqual(['a1', 'a2', 'a3', 'a4'])
|
|
48
|
+
await render({ identity: 'B', newest: page('b', 2, 1, false), fetchOlder })
|
|
49
|
+
expect(ids()).toEqual(['b1', 'b2'])
|
|
50
|
+
expect(latest().truncated).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('drops an older page that arrives after navigating to another workload', async () => {
|
|
54
|
+
const pending = deferred()
|
|
55
|
+
await render({ identity: 'A', newest: page('a', 4, 3, true), fetchOlder: () => pending.promise })
|
|
56
|
+
let loading!: Promise<void>
|
|
57
|
+
await act(async () => { loading = latest().loadOlder() })
|
|
58
|
+
await render({ identity: 'B', newest: page('b', 4, 3, true), fetchOlder: () => pending.promise })
|
|
59
|
+
await act(async () => { pending.resolve(page('a', 2, 1, false)); await loading })
|
|
60
|
+
expect(ids()).toEqual(['b3', 'b4'])
|
|
61
|
+
expect(latest().truncated).toBe(true)
|
|
62
|
+
expect(latest().loadingOlder).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('drops an older page that arrives after a refresh restarted paging', async () => {
|
|
66
|
+
const first = deferred()
|
|
67
|
+
let calls = 0
|
|
68
|
+
const fetchOlder = () => (++calls === 1 ? Promise.resolve(page('', 20, 11, true)) : first.promise)
|
|
69
|
+
await render({ identity: 'A', newest: page('', 30, 21, true), fetchOlder })
|
|
70
|
+
await act(async () => latest().loadOlder())
|
|
71
|
+
let loading!: Promise<void>
|
|
72
|
+
await act(async () => { loading = latest().loadOlder() })
|
|
73
|
+
expect(latest().loadingOlder).toBe(true)
|
|
74
|
+
// More arrived than one page holds: the refreshed page no longer reaches what's loaded.
|
|
75
|
+
await render({ identity: 'A', newest: page('', 60, 51, true), fetchOlder })
|
|
76
|
+
expect(latest().loadingOlder).toBe(false)
|
|
77
|
+
await act(async () => { first.resolve(page('', 10, 1, false)); await loading })
|
|
78
|
+
expect(ids()).toEqual(page('', 60, 51, true).events.map((e) => e.id).sort())
|
|
79
|
+
expect(latest().truncated).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react'
|
|
2
2
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
3
3
|
import { getApiBase } from '../api/config'
|
|
4
|
+
import { readErrorBody } from '../api/httpErrors'
|
|
4
5
|
import { apiFetch, type SyncStatusSnapshot } from '../api/client'
|
|
5
6
|
|
|
6
7
|
export type ConnectionStateType = 'connected' | 'disconnected' | 'connecting'
|
|
@@ -90,7 +91,7 @@ async function retryConnection(): Promise<ConnectionState> {
|
|
|
90
91
|
method: 'POST',
|
|
91
92
|
})
|
|
92
93
|
if (!response.ok) {
|
|
93
|
-
const error = await response
|
|
94
|
+
const error = await readErrorBody(response) as { error?: string; errorType?: string }
|
|
94
95
|
throw new ConnectionRetryError(error.error || `HTTP ${response.status}`, error.errorType)
|
|
95
96
|
}
|
|
96
97
|
return response.json()
|