@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.
Files changed (128) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +65 -17
  4. package/src/RadarApp.tsx +51 -21
  5. package/src/api/apiResources.test.ts +19 -1
  6. package/src/api/apiResources.ts +18 -3
  7. package/src/api/client.admission.test.ts +34 -0
  8. package/src/api/client.capacity.test.ts +15 -0
  9. package/src/api/client.trace.test.ts +9 -1
  10. package/src/api/client.ts +269 -204
  11. package/src/api/diagnose.test.ts +102 -1
  12. package/src/api/diagnose.ts +42 -11
  13. package/src/api/drain-plan.test.ts +0 -10
  14. package/src/api/httpErrors.test.ts +95 -0
  15. package/src/api/httpErrors.ts +101 -0
  16. package/src/api/policy.ts +33 -5
  17. package/src/api/radarFeatures.render.test.tsx +190 -0
  18. package/src/api/radarFeatures.test.ts +133 -0
  19. package/src/api/radarFeatures.ts +147 -0
  20. package/src/api/timelineSource.test.ts +2 -10
  21. package/src/api/timelineSource.ts +3 -2
  22. package/src/api/traffic.ts +4 -3
  23. package/src/api/usage-data.fixtures.ts +33 -0
  24. package/src/api/usage-data.ts +165 -0
  25. package/src/components/CloudFunnelButton.test.tsx +2 -2
  26. package/src/components/CloudFunnelButton.tsx +99 -55
  27. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  28. package/src/components/NamespaceSwitcher.tsx +17 -1
  29. package/src/components/SelfManagedStart.tsx +95 -0
  30. package/src/components/applications/ApplicationsView.tsx +20 -6
  31. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  32. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  33. package/src/components/capacity/CapacityView.test.tsx +13 -4
  34. package/src/components/capacity/shared.tsx +14 -13
  35. package/src/components/cloudConnectHandoff.test.ts +1 -1
  36. package/src/components/cloudConnectHandoff.ts +1 -1
  37. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  38. package/src/components/cost/CostConnectionAction.tsx +30 -0
  39. package/src/components/cost/CostTrendChart.tsx +2 -0
  40. package/src/components/cost/CostView.tsx +4 -24
  41. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  42. package/src/components/cost/source.test.ts +30 -3
  43. package/src/components/cost/source.ts +37 -2
  44. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  45. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  46. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  47. package/src/components/dialog-names.test.ts +69 -0
  48. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  49. package/src/components/dock/TerminalTab.tsx +2 -1
  50. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  51. package/src/components/execution/BatchExecutionView.tsx +1 -1
  52. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  53. package/src/components/execution/JobSetAdmission.tsx +16 -7
  54. package/src/components/gitops/GitOpsView.tsx +12 -0
  55. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  56. package/src/components/home/HomeView.tsx +7 -3
  57. package/src/components/home/MCPSetupDialog.tsx +37 -37
  58. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  59. package/src/components/home/RadarVersionLine.tsx +28 -3
  60. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  61. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  62. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  63. package/src/components/resource/PVCUsageBar.tsx +4 -1
  64. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  65. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  66. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  67. package/src/components/resources/PodFilePreview.tsx +4 -1
  68. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  69. package/src/components/resources/ResourcesView.tsx +2 -1
  70. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  71. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  75. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  76. package/src/components/settings/FormSaveActions.tsx +76 -0
  77. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  78. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  79. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  80. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  81. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  82. package/src/components/settings/PrivacySection.test.tsx +84 -0
  83. package/src/components/settings/PrivacySection.tsx +140 -0
  84. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  85. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  86. package/src/components/settings/SettingsDialog.tsx +393 -571
  87. package/src/components/settings/controls.tsx +55 -0
  88. package/src/components/settings/currency-options.test.ts +1 -1
  89. package/src/components/settings/currency-options.ts +1 -1
  90. package/src/components/settings/settings-state.test.ts +17 -10
  91. package/src/components/settings/settings-state.ts +21 -12
  92. package/src/components/timeline/TimelineList.test.ts +56 -0
  93. package/src/components/timeline/TimelineList.tsx +7 -1
  94. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  95. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  96. package/src/components/traffic/TrafficGraph.tsx +79 -48
  97. package/src/components/traffic/TrafficView.tsx +61 -37
  98. package/src/components/traffic/TrafficWizard.tsx +4 -2
  99. package/src/components/traffic/trafficFilters.test.ts +154 -1
  100. package/src/components/traffic/trafficFilters.ts +141 -0
  101. package/src/components/ui/ErrorBoundary.tsx +5 -0
  102. package/src/components/ui/Omnibar.tsx +17 -1
  103. package/src/components/ui/UpdateNotification.tsx +70 -21
  104. package/src/components/ui/command-items.ts +14 -0
  105. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  106. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  107. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  108. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  109. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  110. package/src/components/whats-new/WhatsNew.tsx +423 -0
  111. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  112. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  113. package/src/components/whats-new/releaseNotes.ts +121 -0
  114. package/src/components/workload/WorkloadView.tsx +47 -14
  115. package/src/components/workload/historyPaging.test.ts +72 -0
  116. package/src/components/workload/historyPaging.ts +147 -0
  117. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  118. package/src/context/ConnectionContext.tsx +2 -1
  119. package/src/context/RadarUpgrade.tsx +30 -0
  120. package/src/context/RadarUpgradeHost.tsx +23 -0
  121. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  122. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  123. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  124. package/src/index.ts +1 -0
  125. package/src/k8s-ui-exports.test.ts +73 -0
  126. package/src/utils/navigation.test.ts +17 -1
  127. package/src/utils/navigation.ts +16 -0
  128. 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
- useChanges,
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
- drainPlanUnsupported: drainPlanMutation.error instanceof DrainPlanUnsupportedError,
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
- // Fetch all events for this resource's namespace (only when expanded)
785
- const { data: allEvents, isLoading: eventsLoading } = useChanges({
786
- namespaces: [namespace],
787
- timeRange: 'all',
788
- includeK8sEvents: true,
789
- includeManaged: true,
790
- limit: 10000,
791
- enabled: expanded,
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.json().catch(() => ({ error: 'Unknown error' })) as { error?: string; errorType?: string }
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()