@skyhook-io/radar-app 0.2.2 → 0.3.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 +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
package/src/api/client.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import type {
|
|
3
|
+
AppHistory,
|
|
4
|
+
AppRow,
|
|
5
|
+
ArgoSyncOpts,
|
|
6
|
+
YamlDocumentIdentity,
|
|
7
|
+
YamlSchemaLoadResult,
|
|
8
|
+
} from '@skyhook-io/k8s-ui'
|
|
1
9
|
import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
|
|
2
10
|
import { showApiError, showApiSuccess } from '../components/ui/Toast'
|
|
3
11
|
import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
|
|
@@ -14,8 +22,12 @@ import type {
|
|
|
14
22
|
HelmReleaseDetail,
|
|
15
23
|
HelmValues,
|
|
16
24
|
ManifestDiff,
|
|
25
|
+
NotesDiff,
|
|
26
|
+
HooksDiff,
|
|
27
|
+
ResourceDiff,
|
|
17
28
|
UpgradeInfo,
|
|
18
29
|
BatchUpgradeInfo,
|
|
30
|
+
ValuesDiff,
|
|
19
31
|
ValuesPreviewResponse,
|
|
20
32
|
HelmRepository,
|
|
21
33
|
ChartSearchResult,
|
|
@@ -23,26 +35,54 @@ import type {
|
|
|
23
35
|
InstallChartRequest,
|
|
24
36
|
ArtifactHubSearchResult,
|
|
25
37
|
ArtifactHubChartDetail,
|
|
38
|
+
GitOpsResourceTree,
|
|
39
|
+
GitOpsInsight,
|
|
40
|
+
GitOpsInsightRef,
|
|
41
|
+
GitOpsResourceDiff,
|
|
42
|
+
ArgoRevisionMetadata,
|
|
26
43
|
} from '../types'
|
|
27
44
|
import type { GitOpsOperationResponse } from '../types/gitops'
|
|
28
45
|
import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
|
|
29
46
|
import { pluralToKind } from '../utils/navigation'
|
|
30
47
|
|
|
48
|
+
// Auto-refresh cadences (ms) — named constants for each polled hook's
|
|
49
|
+
// refetchInterval below, so the poll rate reads clearly at each call site.
|
|
50
|
+
const DASHBOARD_REFRESH_INTERVAL_MS = 30_000
|
|
51
|
+
const AUDIT_REFRESH_INTERVAL_MS = 60_000
|
|
52
|
+
const ISSUES_REFRESH_INTERVAL_MS = 30_000
|
|
53
|
+
const COST_REFRESH_INTERVAL_MS = 60_000
|
|
54
|
+
const COST_DISCOVERY_RETRY_INTERVAL_MS = 5_000
|
|
55
|
+
export const COST_DISCOVERY_GRACE_MS = 30_000
|
|
56
|
+
const COST_TREND_REFRESH_INTERVAL_MS = 120_000
|
|
57
|
+
const CHANGES_REFRESH_INTERVAL_MS = 60_000
|
|
58
|
+
const APPLICATIONS_REFRESH_INTERVAL_MS = 60_000
|
|
59
|
+
|
|
31
60
|
// Wrapper around fetch that always includes credentials (for session cookies)
|
|
32
61
|
// and handles 401 responses globally. Merges caller-provided headers with
|
|
33
62
|
// auth headers from the config module so library consumers (Radar Hub) can
|
|
34
63
|
// inject Authorization bearer tokens without each call site knowing.
|
|
35
|
-
function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
64
|
+
export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
36
65
|
const headers = new Headers(init?.headers)
|
|
37
66
|
for (const [k, v] of Object.entries(getAuthHeaders())) {
|
|
38
67
|
if (!headers.has(k)) headers.set(k, v)
|
|
39
68
|
}
|
|
40
|
-
return fetch(input, {
|
|
69
|
+
return fetch(input, {
|
|
70
|
+
credentials: getCredentialsMode(),
|
|
71
|
+
...init,
|
|
72
|
+
headers,
|
|
73
|
+
}).then(async (response) => {
|
|
41
74
|
const authPrefix = `${getBasename()}/auth`
|
|
42
75
|
if (response.status === 401 && !window.location.pathname.startsWith(authPrefix)) {
|
|
43
76
|
// Save current location so user returns to where they were after re-auth.
|
|
44
77
|
// Editor draft is auto-saved by EditableYamlView via sessionStorage.
|
|
45
|
-
try {
|
|
78
|
+
try {
|
|
79
|
+
sessionStorage.setItem(
|
|
80
|
+
'radar_return_path',
|
|
81
|
+
window.location.pathname + window.location.search,
|
|
82
|
+
)
|
|
83
|
+
} catch {
|
|
84
|
+
/* best-effort */
|
|
85
|
+
}
|
|
46
86
|
|
|
47
87
|
let authMode: string | undefined
|
|
48
88
|
try {
|
|
@@ -61,7 +101,11 @@ function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Respons
|
|
|
61
101
|
const lastReload = sessionStorage.getItem('radar_proxy_reload')
|
|
62
102
|
const now = Date.now()
|
|
63
103
|
if (!lastReload || now - parseInt(lastReload) > 5000) {
|
|
64
|
-
try {
|
|
104
|
+
try {
|
|
105
|
+
sessionStorage.setItem('radar_proxy_reload', String(now))
|
|
106
|
+
} catch {
|
|
107
|
+
/* best-effort */
|
|
108
|
+
}
|
|
65
109
|
window.location.reload()
|
|
66
110
|
}
|
|
67
111
|
}
|
|
@@ -86,8 +130,50 @@ export function isForbiddenError(error: unknown): boolean {
|
|
|
86
130
|
return error instanceof ApiError && error.status === 403
|
|
87
131
|
}
|
|
88
132
|
|
|
89
|
-
|
|
90
|
-
|
|
133
|
+
const METRICS_API_GROUP_TOKENS = ['metrics', 'k8s', 'io'] as const
|
|
134
|
+
|
|
135
|
+
function mentionsMetricsAPIGroup(message: string): boolean {
|
|
136
|
+
const tokens = message.split(/[^a-z0-9]+/).filter(Boolean)
|
|
137
|
+
return tokens.some(
|
|
138
|
+
(token, index) =>
|
|
139
|
+
token === METRICS_API_GROUP_TOKENS[0] &&
|
|
140
|
+
tokens[index + 1] === METRICS_API_GROUP_TOKENS[1] &&
|
|
141
|
+
tokens[index + 2] === METRICS_API_GROUP_TOKENS[2],
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function hasMetricsUnavailablePhrase(message: string): boolean {
|
|
146
|
+
return (
|
|
147
|
+
message.includes('may not be installed') ||
|
|
148
|
+
message.includes('not found') ||
|
|
149
|
+
message.includes('could not find the requested resource') ||
|
|
150
|
+
message.includes('no matches for kind') ||
|
|
151
|
+
message.includes('no resource matches') ||
|
|
152
|
+
message.includes('no metrics known') ||
|
|
153
|
+
message.includes('not available') ||
|
|
154
|
+
message.includes('unable to fetch metrics') ||
|
|
155
|
+
message.includes('currently unable to handle the request')
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isMetricsUnavailableError(error: unknown): boolean {
|
|
160
|
+
if (!(error instanceof ApiError)) return false
|
|
161
|
+
if (error.status !== 404 && error.status !== 500) return false
|
|
162
|
+
return [error.message, error.data?.error].some((message) => {
|
|
163
|
+
if (typeof message !== 'string') return false
|
|
164
|
+
const normalized = message.toLowerCase()
|
|
165
|
+
const hasMetricsSignal =
|
|
166
|
+
normalized.includes('metrics-server') ||
|
|
167
|
+
mentionsMetricsAPIGroup(normalized) ||
|
|
168
|
+
normalized.includes('pod metrics') ||
|
|
169
|
+
normalized.includes('node metrics')
|
|
170
|
+
return hasMetricsSignal && hasMetricsUnavailablePhrase(normalized)
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function fetchJSON<T>(path: string, init?: RequestInit | AbortSignal): Promise<T> {
|
|
175
|
+
const requestInit = init instanceof AbortSignal ? { signal: init } : init
|
|
176
|
+
const response = await apiFetch(`${getApiBase()}${path}`, requestInit)
|
|
91
177
|
if (!response.ok) {
|
|
92
178
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
93
179
|
throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
|
|
@@ -118,7 +204,7 @@ export interface DashboardProblem {
|
|
|
118
204
|
namespace: string
|
|
119
205
|
name: string
|
|
120
206
|
group?: string
|
|
121
|
-
severity: 'critical' | 'high' | 'medium'
|
|
207
|
+
severity: 'critical' | 'high' | 'medium' | 'warning' | 'info'
|
|
122
208
|
reason: string
|
|
123
209
|
message: string
|
|
124
210
|
age: string
|
|
@@ -137,6 +223,9 @@ export interface WorkloadCount {
|
|
|
137
223
|
export interface DashboardMetrics {
|
|
138
224
|
cpu?: MetricSummary
|
|
139
225
|
memory?: MetricSummary
|
|
226
|
+
// When false, only requests/capacity are meaningful — live usage (from
|
|
227
|
+
// metrics-server) is unavailable and usage fields are zero.
|
|
228
|
+
usageAvailable: boolean
|
|
140
229
|
}
|
|
141
230
|
|
|
142
231
|
export interface MetricSummary {
|
|
@@ -148,7 +237,13 @@ export interface MetricSummary {
|
|
|
148
237
|
}
|
|
149
238
|
|
|
150
239
|
export interface DashboardResourceCounts {
|
|
151
|
-
pods: {
|
|
240
|
+
pods: {
|
|
241
|
+
total: number
|
|
242
|
+
running: number
|
|
243
|
+
pending: number
|
|
244
|
+
failed: number
|
|
245
|
+
succeeded: number
|
|
246
|
+
}
|
|
152
247
|
deployments: { total: number; available: number; unavailable: number }
|
|
153
248
|
statefulSets: WorkloadCount
|
|
154
249
|
daemonSets: WorkloadCount
|
|
@@ -166,15 +261,6 @@ export interface DashboardResourceCounts {
|
|
|
166
261
|
restricted?: string[] // Resource kinds the user cannot list due to RBAC
|
|
167
262
|
}
|
|
168
263
|
|
|
169
|
-
export interface DashboardEvent {
|
|
170
|
-
type: string
|
|
171
|
-
reason: string
|
|
172
|
-
message: string
|
|
173
|
-
involvedObject: string
|
|
174
|
-
namespace: string
|
|
175
|
-
timestamp: string
|
|
176
|
-
}
|
|
177
|
-
|
|
178
264
|
export interface DashboardChange {
|
|
179
265
|
kind: string
|
|
180
266
|
namespace: string
|
|
@@ -231,15 +317,27 @@ export interface DashboardCRDCount {
|
|
|
231
317
|
}
|
|
232
318
|
|
|
233
319
|
// Re-export shared types from k8s-ui — single source of truth
|
|
234
|
-
import type {
|
|
320
|
+
import type {
|
|
321
|
+
AuditCardData,
|
|
322
|
+
AuditFinding,
|
|
323
|
+
ResourceGroup,
|
|
324
|
+
CheckMeta,
|
|
325
|
+
Check,
|
|
326
|
+
Issue,
|
|
327
|
+
IssueRecentChange,
|
|
328
|
+
} from '@skyhook-io/k8s-ui'
|
|
235
329
|
export type DashboardAudit = AuditCardData
|
|
236
|
-
export type { AuditFinding, ResourceGroup, CheckMeta }
|
|
330
|
+
export type { AuditFinding, ResourceGroup, CheckMeta, Check }
|
|
237
331
|
|
|
238
332
|
export interface AuditResponse {
|
|
239
333
|
summary: DashboardAudit
|
|
240
334
|
findings: AuditFinding[]
|
|
241
335
|
groups: ResourceGroup[]
|
|
242
336
|
checks: Record<string, CheckMeta>
|
|
337
|
+
// Remediation-queue rollup: findings grouped by check, prioritized. Present
|
|
338
|
+
// on the non-raw scan (standalone + embedded per-cluster views); the Checks
|
|
339
|
+
// queue renders this.
|
|
340
|
+
groupedChecks?: Check[]
|
|
243
341
|
}
|
|
244
342
|
|
|
245
343
|
export interface DashboardCertificateHealth {
|
|
@@ -256,12 +354,36 @@ export interface DashboardNetworkPolicyCoverage {
|
|
|
256
354
|
totalWorkloads: number
|
|
257
355
|
}
|
|
258
356
|
|
|
357
|
+
export interface DashboardGitOpsControllers {
|
|
358
|
+
// Aggregate roll-up across all detected controllers.
|
|
359
|
+
status: 'healthy' | 'degraded' | 'crashing'
|
|
360
|
+
controllers: DashboardGitOpsController[]
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export interface DashboardGitOpsController {
|
|
364
|
+
name: string
|
|
365
|
+
// Tool vocabulary aligns with the backend `ctrlTool*` constants in
|
|
366
|
+
// internal/server/dashboard_gitops.go and the GitOps tree-builder
|
|
367
|
+
// tags in pkg/gitops/tree/graph.go (`gitopsTool`). Keep these three
|
|
368
|
+
// surfaces in sync — diverging vocabulary across surfaces was a real
|
|
369
|
+
// source of confusion until consolidated.
|
|
370
|
+
tool: 'argocd' | 'fluxcd'
|
|
371
|
+
namespace: string
|
|
372
|
+
ready: number
|
|
373
|
+
total: number
|
|
374
|
+
// Per-controller status (aggregate is in the parent and excludes
|
|
375
|
+
// 'pending' — it normalizes into 'degraded' there).
|
|
376
|
+
status: 'healthy' | 'degraded' | 'crashing' | 'pending'
|
|
377
|
+
// Reason for the crash when status === 'crashing'. Common values:
|
|
378
|
+
// "CrashLoopBackOff", "Error". Empty for non-crashing states.
|
|
379
|
+
crashReason?: string
|
|
380
|
+
}
|
|
381
|
+
|
|
259
382
|
export interface DashboardResponse {
|
|
260
383
|
cluster: DashboardCluster
|
|
261
384
|
health: DashboardHealth
|
|
262
385
|
problems: DashboardProblem[]
|
|
263
386
|
resourceCounts: DashboardResourceCounts
|
|
264
|
-
recentEvents: DashboardEvent[]
|
|
265
387
|
recentChanges: DashboardChange[]
|
|
266
388
|
topologySummary: DashboardTopologySummary
|
|
267
389
|
trafficSummary: DashboardTrafficSummary | null
|
|
@@ -270,9 +392,14 @@ export interface DashboardResponse {
|
|
|
270
392
|
certificateHealth: DashboardCertificateHealth | null
|
|
271
393
|
networkPolicyCoverage: DashboardNetworkPolicyCoverage | null
|
|
272
394
|
audit: DashboardAudit | null
|
|
273
|
-
|
|
395
|
+
gitopsControllers: DashboardGitOpsControllers | null
|
|
396
|
+
nodeVersionSkew: {
|
|
397
|
+
versions: Record<string, string[]>
|
|
398
|
+
minVersion: string
|
|
399
|
+
maxVersion: string
|
|
400
|
+
} | null
|
|
274
401
|
deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
|
|
275
|
-
partialData?: string[] //
|
|
402
|
+
partialData?: string[] // Critical kinds promoted at first paint that haven't yet finished syncing (live-filtered)
|
|
276
403
|
accessRestricted?: boolean // True when user has no namespace access (RBAC)
|
|
277
404
|
}
|
|
278
405
|
|
|
@@ -280,13 +407,14 @@ export interface DashboardCRDsResponse {
|
|
|
280
407
|
topCRDs: DashboardCRDCount[]
|
|
281
408
|
}
|
|
282
409
|
|
|
283
|
-
export function useDashboard(namespaces: string[] = []) {
|
|
410
|
+
export function useDashboard(namespaces: string[] = [], options?: { enabled?: boolean }) {
|
|
284
411
|
const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
|
|
285
412
|
return useQuery<DashboardResponse>({
|
|
286
413
|
queryKey: ['dashboard', namespaces],
|
|
287
414
|
queryFn: () => fetchJSON(`/dashboard${params}`),
|
|
415
|
+
enabled: options?.enabled ?? true,
|
|
288
416
|
staleTime: 15000, // 15 seconds
|
|
289
|
-
refetchInterval:
|
|
417
|
+
refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
|
|
290
418
|
})
|
|
291
419
|
}
|
|
292
420
|
|
|
@@ -297,7 +425,40 @@ export function useAudit(namespaces: string[] = []) {
|
|
|
297
425
|
queryKey: ['audit', namespaces],
|
|
298
426
|
queryFn: () => fetchJSON(`/audit${params}`),
|
|
299
427
|
staleTime: 30000,
|
|
300
|
-
refetchInterval:
|
|
428
|
+
refetchInterval: AUDIT_REFRESH_INTERVAL_MS,
|
|
429
|
+
placeholderData: (prev) => prev,
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Live cluster Issues — the grouped triage queue (radar's /api/issues =
|
|
434
|
+
// internal/issues.Compose+Classify+Group). Single-cluster here; the Hub fleet
|
|
435
|
+
// view fans the same shape across clusters. Do not carry old data across query
|
|
436
|
+
// keys: issues are scope-sensitive, so namespace changes must not show the
|
|
437
|
+
// previous scope's rows while the new scope fetches.
|
|
438
|
+
// total = rows returned (after the cap); total_matched = rows that matched
|
|
439
|
+
// before the cap. total_matched > total means the queue was truncated — surface
|
|
440
|
+
// that honestly rather than presenting a capped list as if it were complete.
|
|
441
|
+
export interface IssuesResponse {
|
|
442
|
+
issues: Issue[]
|
|
443
|
+
total?: number
|
|
444
|
+
total_matched?: number
|
|
445
|
+
recent_changes?: IssueRecentChange[]
|
|
446
|
+
recent_changes_reason?: string
|
|
447
|
+
recent_changes_guidance?: string
|
|
448
|
+
recent_changes_truncated?: boolean
|
|
449
|
+
// Present only when RBAC visibility is incomplete (absent = full access).
|
|
450
|
+
// state 'degraded' means core workload reads are denied, so an empty list may
|
|
451
|
+
// mean "can't see" rather than "nothing broken" — the UI must say so.
|
|
452
|
+
visibility?: { state?: string; impact?: string }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function useIssues(namespaces: string[] = []) {
|
|
456
|
+
const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
|
|
457
|
+
return useQuery<IssuesResponse>({
|
|
458
|
+
queryKey: ['issues', namespaces],
|
|
459
|
+
queryFn: () => fetchJSON(`/issues${params}`),
|
|
460
|
+
staleTime: 30000,
|
|
461
|
+
refetchInterval: ISSUES_REFRESH_INTERVAL_MS,
|
|
301
462
|
})
|
|
302
463
|
}
|
|
303
464
|
|
|
@@ -309,6 +470,34 @@ export function useResourceAudit(kind: string, namespace: string, name: string)
|
|
|
309
470
|
})
|
|
310
471
|
}
|
|
311
472
|
|
|
473
|
+
// Live Issues that touch ONE resource — its own issues plus, for a workload, its
|
|
474
|
+
// owned pods' issues (server-side owner rollup via issues.RelatedIssues). Backs
|
|
475
|
+
// the "Operational Issues" section in the resource detail. Cluster-scoped
|
|
476
|
+
// resources pass "_" for namespace; namespaced ones also scope the scan via
|
|
477
|
+
// ?namespaces= for a cheap, bounded Compose.
|
|
478
|
+
export function useResourceIssues(
|
|
479
|
+
kind: string,
|
|
480
|
+
group: string | undefined,
|
|
481
|
+
namespace: string,
|
|
482
|
+
name: string,
|
|
483
|
+
enabled = true,
|
|
484
|
+
) {
|
|
485
|
+
const clusterScoped = !namespace
|
|
486
|
+
const pathNs = clusterScoped ? '_' : encodeURIComponent(namespace)
|
|
487
|
+
const params = new URLSearchParams()
|
|
488
|
+
if (group) params.set('group', group)
|
|
489
|
+
const path = `/issues/resource/${encodeURIComponent(kind)}/${pathNs}/${encodeURIComponent(name)}`
|
|
490
|
+
const qs = params.toString()
|
|
491
|
+
return useQuery<Issue[]>({
|
|
492
|
+
queryKey: ['issues', 'resource', kind, group ?? '', namespace, name],
|
|
493
|
+
queryFn: () => fetchJSON(`${path}${qs ? `?${qs}` : ''}`),
|
|
494
|
+
// No refetchInterval: a drawer doesn't need to poll; staleTime keeps it fresh
|
|
495
|
+
// on reopen without re-running an uncapped Compose every 30s.
|
|
496
|
+
staleTime: 30000,
|
|
497
|
+
enabled: enabled && !!kind && !!name,
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
|
|
312
501
|
// Audit settings
|
|
313
502
|
export interface AuditSettings {
|
|
314
503
|
ignoredNamespaces: string[]
|
|
@@ -405,7 +594,8 @@ export interface OpenCostNamespaceCost {
|
|
|
405
594
|
idleCost?: number
|
|
406
595
|
}
|
|
407
596
|
|
|
408
|
-
export type CostUnavailableReason =
|
|
597
|
+
export type CostUnavailableReason =
|
|
598
|
+
'no_prometheus' | 'no_metrics' | 'query_error' | 'access_denied' | 'not_found'
|
|
409
599
|
|
|
410
600
|
export interface OpenCostSummary {
|
|
411
601
|
available: boolean
|
|
@@ -419,11 +609,41 @@ export interface OpenCostSummary {
|
|
|
419
609
|
namespaces?: OpenCostNamespaceCost[]
|
|
420
610
|
}
|
|
421
611
|
|
|
612
|
+
const noPrometheusFirstSeenAt = new Map<string, number>()
|
|
613
|
+
|
|
614
|
+
function costRefetchInterval(
|
|
615
|
+
defaultInterval: number | false = COST_REFRESH_INTERVAL_MS,
|
|
616
|
+
contextName?: string,
|
|
617
|
+
) {
|
|
618
|
+
return (query: {
|
|
619
|
+
queryHash?: string
|
|
620
|
+
queryKey?: unknown
|
|
621
|
+
state: {
|
|
622
|
+
data?: { available?: boolean; reason?: CostUnavailableReason }
|
|
623
|
+
dataUpdatedAt?: number
|
|
624
|
+
}
|
|
625
|
+
}) => {
|
|
626
|
+
const data = query.state.data
|
|
627
|
+
const queryID = `${contextName ?? 'unknown'}:${query.queryHash ?? JSON.stringify(query.queryKey ?? 'opencost')}`
|
|
628
|
+
if (data?.available === false && data.reason === 'no_prometheus') {
|
|
629
|
+
const now = Date.now()
|
|
630
|
+
const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? now
|
|
631
|
+
noPrometheusFirstSeenAt.set(queryID, firstSeenAt)
|
|
632
|
+
return now - firstSeenAt < COST_DISCOVERY_GRACE_MS
|
|
633
|
+
? COST_DISCOVERY_RETRY_INTERVAL_MS
|
|
634
|
+
: defaultInterval
|
|
635
|
+
}
|
|
636
|
+
noPrometheusFirstSeenAt.delete(queryID)
|
|
637
|
+
return defaultInterval
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
422
641
|
export function useOpenCostSummary() {
|
|
642
|
+
const clusterInfo = useClusterInfo()
|
|
423
643
|
return useQuery<OpenCostSummary>({
|
|
424
644
|
queryKey: ['opencost-summary'],
|
|
425
645
|
queryFn: () => fetchJSON('/opencost/summary'),
|
|
426
|
-
refetchInterval:
|
|
646
|
+
refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
427
647
|
staleTime: 30000,
|
|
428
648
|
placeholderData: (prev) => prev, // Keep previous data visible during refetch
|
|
429
649
|
})
|
|
@@ -439,6 +659,10 @@ export interface OpenCostWorkloadCost {
|
|
|
439
659
|
replicas: number
|
|
440
660
|
cpuUsageCost?: number
|
|
441
661
|
memoryUsageCost?: number
|
|
662
|
+
cpuUsageAvailable: boolean
|
|
663
|
+
memoryUsageAvailable: boolean
|
|
664
|
+
cpuAllocationUse: number
|
|
665
|
+
memoryAllocationUse: number
|
|
442
666
|
efficiency?: number
|
|
443
667
|
idleCost?: number
|
|
444
668
|
}
|
|
@@ -451,11 +675,41 @@ export interface OpenCostWorkloadResponse {
|
|
|
451
675
|
}
|
|
452
676
|
|
|
453
677
|
export function useOpenCostWorkloads(namespace: string, options?: { enabled?: boolean }) {
|
|
678
|
+
const clusterInfo = useClusterInfo()
|
|
454
679
|
return useQuery<OpenCostWorkloadResponse>({
|
|
455
680
|
queryKey: ['opencost-workloads', namespace],
|
|
456
681
|
queryFn: () => fetchJSON(`/opencost/workloads?namespace=${encodeURIComponent(namespace)}`),
|
|
457
682
|
enabled: (options?.enabled ?? true) && Boolean(namespace),
|
|
683
|
+
refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
684
|
+
staleTime: 30000,
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export interface OpenCostWorkloadDetailResponse {
|
|
689
|
+
available: boolean
|
|
690
|
+
reason?: CostUnavailableReason
|
|
691
|
+
namespace: string
|
|
692
|
+
kind: string
|
|
693
|
+
name: string
|
|
694
|
+
current?: OpenCostWorkloadCost
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export function useOpenCostWorkload(
|
|
698
|
+
kind: string,
|
|
699
|
+
namespace: string,
|
|
700
|
+
name: string,
|
|
701
|
+
options?: { enabled?: boolean },
|
|
702
|
+
) {
|
|
703
|
+
const clusterInfo = useClusterInfo()
|
|
704
|
+
return useQuery<OpenCostWorkloadDetailResponse>({
|
|
705
|
+
queryKey: ['opencost-workload', kind, namespace, name],
|
|
706
|
+
queryFn: () =>
|
|
707
|
+
fetchJSON(
|
|
708
|
+
`/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`,
|
|
709
|
+
),
|
|
710
|
+
enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
|
|
458
711
|
staleTime: 30000,
|
|
712
|
+
refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
459
713
|
})
|
|
460
714
|
}
|
|
461
715
|
|
|
@@ -480,18 +734,175 @@ export interface OpenCostTrendResponse {
|
|
|
480
734
|
}
|
|
481
735
|
|
|
482
736
|
export function useOpenCostTrend(range_: CostTimeRange = '24h') {
|
|
737
|
+
const clusterInfo = useClusterInfo()
|
|
483
738
|
return useQuery<OpenCostTrendResponse>({
|
|
484
739
|
queryKey: ['opencost-trend', range_],
|
|
485
740
|
queryFn: () => fetchJSON(`/opencost/trend?range=${range_}`),
|
|
486
741
|
staleTime: 60000,
|
|
487
|
-
refetchInterval:
|
|
742
|
+
refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
488
743
|
placeholderData: (prev) => prev,
|
|
489
744
|
})
|
|
490
745
|
}
|
|
491
746
|
|
|
747
|
+
export interface OpenCostWorkloadTrendResponse {
|
|
748
|
+
available: boolean
|
|
749
|
+
reason?: CostUnavailableReason
|
|
750
|
+
namespace: string
|
|
751
|
+
kind: string
|
|
752
|
+
name: string
|
|
753
|
+
range: string
|
|
754
|
+
windowTotalCost?: number
|
|
755
|
+
dataPoints?: OpenCostTrendDataPoint[]
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
export function useOpenCostWorkloadTrend(
|
|
759
|
+
kind: string,
|
|
760
|
+
namespace: string,
|
|
761
|
+
name: string,
|
|
762
|
+
range_: CostTimeRange = '24h',
|
|
763
|
+
options?: { enabled?: boolean },
|
|
764
|
+
) {
|
|
765
|
+
const clusterInfo = useClusterInfo()
|
|
766
|
+
return useQuery<OpenCostWorkloadTrendResponse>({
|
|
767
|
+
queryKey: ['opencost-workload-trend', kind, namespace, name, range_],
|
|
768
|
+
queryFn: () =>
|
|
769
|
+
fetchJSON(
|
|
770
|
+
`/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/trend?range=${range_}`,
|
|
771
|
+
),
|
|
772
|
+
enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
|
|
773
|
+
staleTime: 60000,
|
|
774
|
+
refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export interface OpenCostApplicationWorkloadRef {
|
|
779
|
+
kind: string
|
|
780
|
+
namespace: string
|
|
781
|
+
name: string
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export interface OpenCostApplicationWorkloadStatus extends OpenCostApplicationWorkloadRef {
|
|
785
|
+
reason: CostUnavailableReason
|
|
786
|
+
scaledToZero?: boolean
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export interface OpenCostApplicationCostCoverage {
|
|
790
|
+
total: number
|
|
791
|
+
included: number
|
|
792
|
+
unavailable?: OpenCostApplicationWorkloadStatus[]
|
|
793
|
+
unsupported?: OpenCostApplicationWorkloadRef[]
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export interface OpenCostApplicationCostTotals {
|
|
797
|
+
hourlyCost: number
|
|
798
|
+
cpuCost: number
|
|
799
|
+
memoryCost: number
|
|
800
|
+
replicas: number
|
|
801
|
+
cpuUsageCost?: number
|
|
802
|
+
memoryUsageCost?: number
|
|
803
|
+
cpuUsageAvailable: boolean
|
|
804
|
+
memoryUsageAvailable: boolean
|
|
805
|
+
cpuAllocationUse: number
|
|
806
|
+
memoryAllocationUse: number
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWorkloadRef {
|
|
810
|
+
available: boolean
|
|
811
|
+
reason?: CostUnavailableReason
|
|
812
|
+
scaledToZero?: boolean
|
|
813
|
+
current?: OpenCostWorkloadCost
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
export interface OpenCostApplicationCostResponse {
|
|
817
|
+
available: boolean
|
|
818
|
+
reason?: CostUnavailableReason
|
|
819
|
+
partial?: boolean
|
|
820
|
+
totals: OpenCostApplicationCostTotals
|
|
821
|
+
coverage: OpenCostApplicationCostCoverage
|
|
822
|
+
workloads?: OpenCostApplicationWorkloadCost[]
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationWorkloadRef {
|
|
826
|
+
windowTotalCost?: number
|
|
827
|
+
dataPoints?: OpenCostTrendDataPoint[]
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export interface OpenCostApplicationCostTrendResponse {
|
|
831
|
+
available: boolean
|
|
832
|
+
reason?: CostUnavailableReason
|
|
833
|
+
range: string
|
|
834
|
+
partial?: boolean
|
|
835
|
+
windowTotalCost?: number
|
|
836
|
+
dataPoints?: OpenCostTrendDataPoint[]
|
|
837
|
+
series?: OpenCostApplicationCostTrendSeries[]
|
|
838
|
+
coverage: OpenCostApplicationCostCoverage
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function stableOpenCostWorkloadRefs(
|
|
842
|
+
workloads: OpenCostApplicationWorkloadRef[],
|
|
843
|
+
): OpenCostApplicationWorkloadRef[] {
|
|
844
|
+
const byKey = new Map<string, OpenCostApplicationWorkloadRef>()
|
|
845
|
+
for (const workload of workloads) {
|
|
846
|
+
if (!workload.kind || !workload.namespace || !workload.name) continue
|
|
847
|
+
const ref = {
|
|
848
|
+
kind: workload.kind,
|
|
849
|
+
namespace: workload.namespace,
|
|
850
|
+
name: workload.name,
|
|
851
|
+
}
|
|
852
|
+
byKey.set(`${ref.namespace}/${ref.kind}/${ref.name}`, ref)
|
|
853
|
+
}
|
|
854
|
+
return [...byKey.values()].sort((a, b) =>
|
|
855
|
+
`${a.namespace}/${a.kind}/${a.name}`.localeCompare(`${b.namespace}/${b.kind}/${b.name}`),
|
|
856
|
+
)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export function useOpenCostApplicationCost(
|
|
860
|
+
workloads: OpenCostApplicationWorkloadRef[],
|
|
861
|
+
options?: { enabled?: boolean },
|
|
862
|
+
) {
|
|
863
|
+
const clusterInfo = useClusterInfo()
|
|
864
|
+
const refs = stableOpenCostWorkloadRefs(workloads)
|
|
865
|
+
return useQuery<OpenCostApplicationCostResponse>({
|
|
866
|
+
queryKey: ['opencost-application', refs],
|
|
867
|
+
queryFn: ({ signal }) =>
|
|
868
|
+
fetchJSON('/opencost/application', {
|
|
869
|
+
method: 'POST',
|
|
870
|
+
headers: { 'Content-Type': 'application/json' },
|
|
871
|
+
body: JSON.stringify({ workloads: refs }),
|
|
872
|
+
signal,
|
|
873
|
+
}),
|
|
874
|
+
enabled: (options?.enabled ?? true) && refs.length > 0,
|
|
875
|
+
staleTime: 30000,
|
|
876
|
+
refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
877
|
+
})
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
export function useOpenCostApplicationCostTrend(
|
|
881
|
+
workloads: OpenCostApplicationWorkloadRef[],
|
|
882
|
+
range_: CostTimeRange = '24h',
|
|
883
|
+
options?: { enabled?: boolean },
|
|
884
|
+
) {
|
|
885
|
+
const clusterInfo = useClusterInfo()
|
|
886
|
+
const refs = stableOpenCostWorkloadRefs(workloads)
|
|
887
|
+
return useQuery<OpenCostApplicationCostTrendResponse>({
|
|
888
|
+
queryKey: ['opencost-application-trend', refs, range_],
|
|
889
|
+
queryFn: ({ signal }) =>
|
|
890
|
+
fetchJSON('/opencost/application/trend', {
|
|
891
|
+
method: 'POST',
|
|
892
|
+
headers: { 'Content-Type': 'application/json' },
|
|
893
|
+
body: JSON.stringify({ workloads: refs, range: range_ }),
|
|
894
|
+
signal,
|
|
895
|
+
}),
|
|
896
|
+
enabled: (options?.enabled ?? true) && refs.length > 0,
|
|
897
|
+
staleTime: 60000,
|
|
898
|
+
refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
899
|
+
})
|
|
900
|
+
}
|
|
901
|
+
|
|
492
902
|
// Node cost breakdown
|
|
493
903
|
export interface OpenCostNodeCost {
|
|
494
904
|
name: string
|
|
905
|
+
providerID?: string
|
|
495
906
|
instanceType?: string
|
|
496
907
|
region?: string
|
|
497
908
|
hourlyCost: number
|
|
@@ -506,11 +917,12 @@ export interface OpenCostNodeResponse {
|
|
|
506
917
|
}
|
|
507
918
|
|
|
508
919
|
export function useOpenCostNodes() {
|
|
920
|
+
const clusterInfo = useClusterInfo()
|
|
509
921
|
return useQuery<OpenCostNodeResponse>({
|
|
510
922
|
queryKey: ['opencost-nodes'],
|
|
511
923
|
queryFn: () => fetchJSON('/opencost/nodes'),
|
|
512
924
|
staleTime: 60000,
|
|
513
|
-
refetchInterval:
|
|
925
|
+
refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
514
926
|
placeholderData: (prev) => prev,
|
|
515
927
|
})
|
|
516
928
|
}
|
|
@@ -623,6 +1035,84 @@ export interface RuntimeStats {
|
|
|
623
1035
|
dynamicInformers?: number
|
|
624
1036
|
}
|
|
625
1037
|
|
|
1038
|
+
// ============================================================================
|
|
1039
|
+
// Resource search (GET /api/search) — the existing search engine, RBAC-filtered
|
|
1040
|
+
// and ranked server-side. Mirrors internal/search.Hit / .Result.
|
|
1041
|
+
// ============================================================================
|
|
1042
|
+
|
|
1043
|
+
export interface SearchMatchedField {
|
|
1044
|
+
token: string
|
|
1045
|
+
/** "name" | "namespace" | "label:k" | "annotation:k" | "image" | "kind" | "content:path" */
|
|
1046
|
+
site: string
|
|
1047
|
+
score: number
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
export interface SearchSummaryContext {
|
|
1051
|
+
health?: string
|
|
1052
|
+
issueCount?: number
|
|
1053
|
+
managedBy?: { kind?: string; name?: string } | null
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
export interface SearchHit {
|
|
1057
|
+
score: number
|
|
1058
|
+
kind: string
|
|
1059
|
+
group?: string
|
|
1060
|
+
namespace?: string
|
|
1061
|
+
name: string
|
|
1062
|
+
matched?: SearchMatchedField[]
|
|
1063
|
+
summaryContext?: SearchSummaryContext
|
|
1064
|
+
/** Embedder (Radar Hub) only: the cluster this hit belongs to, for
|
|
1065
|
+
* cross-cluster fleet search. Standalone Radar (single-cluster) leaves these
|
|
1066
|
+
* unset — the omnibar keys + displays the cluster only when present. */
|
|
1067
|
+
cluster?: string
|
|
1068
|
+
clusterName?: string
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
export interface SearchResult {
|
|
1072
|
+
hits: SearchHit[]
|
|
1073
|
+
total: number
|
|
1074
|
+
searched: number
|
|
1075
|
+
total_matched: number
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const SEARCH_MIN_QUERY = 2
|
|
1079
|
+
|
|
1080
|
+
// useSearch hits the resource-search engine. The caller supplies the (already
|
|
1081
|
+
// debounced) query; the hook is enabled only past the min length. include=none
|
|
1082
|
+
// keeps the per-hit payload identity-only; context=summary attaches
|
|
1083
|
+
// health/issueCount per hit (rich rows). React Query's AbortSignal cancels
|
|
1084
|
+
// overlapping scans on a new query. keepPreviousData avoids flicker while the
|
|
1085
|
+
// next query resolves.
|
|
1086
|
+
export function useSearch(
|
|
1087
|
+
query: string,
|
|
1088
|
+
opts?: {
|
|
1089
|
+
limit?: number
|
|
1090
|
+
context?: 'summary' | 'none'
|
|
1091
|
+
enabled?: boolean
|
|
1092
|
+
globalNs?: boolean
|
|
1093
|
+
},
|
|
1094
|
+
) {
|
|
1095
|
+
const trimmed = query.trim()
|
|
1096
|
+
const enabled = (opts?.enabled ?? true) && trimmed.length >= SEARCH_MIN_QUERY
|
|
1097
|
+
const limit = opts?.limit ?? 20
|
|
1098
|
+
const context = opts?.context ?? 'summary'
|
|
1099
|
+
// globalNs makes search ignore the per-user namespace-switcher pick and scan
|
|
1100
|
+
// the user's full RBAC ceiling (scope then comes only from the query's `ns:`
|
|
1101
|
+
// tokens). The omnibar opts in so ⌘K is a genuinely global lookup.
|
|
1102
|
+
const globalNs = opts?.globalNs ?? false
|
|
1103
|
+
return useQuery<SearchResult>({
|
|
1104
|
+
queryKey: ['search', trimmed, limit, context, globalNs],
|
|
1105
|
+
queryFn: ({ signal }) =>
|
|
1106
|
+
fetchJSON<SearchResult>(
|
|
1107
|
+
`/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`,
|
|
1108
|
+
signal,
|
|
1109
|
+
),
|
|
1110
|
+
enabled,
|
|
1111
|
+
staleTime: 2000,
|
|
1112
|
+
placeholderData: (prev) => prev, // keepPreviousData
|
|
1113
|
+
})
|
|
1114
|
+
}
|
|
1115
|
+
|
|
626
1116
|
export interface HealthResponse {
|
|
627
1117
|
status: string
|
|
628
1118
|
resourceCount: number
|
|
@@ -649,11 +1139,13 @@ export function useCapabilities() {
|
|
|
649
1139
|
})
|
|
650
1140
|
}
|
|
651
1141
|
|
|
652
|
-
// Namespace-scoped capabilities
|
|
653
|
-
// global RBAC checks denied them. Users with namespace-scoped RoleBindings may
|
|
1142
|
+
// Namespace-scoped capabilities. Users with namespace-scoped RoleBindings may
|
|
654
1143
|
// have these permissions in specific namespaces.
|
|
655
|
-
export function useNamespaceCapabilities(
|
|
656
|
-
|
|
1144
|
+
export function useNamespaceCapabilities(
|
|
1145
|
+
namespace: string | undefined,
|
|
1146
|
+
globalCaps: Capabilities | undefined,
|
|
1147
|
+
) {
|
|
1148
|
+
const needsCheck = namespace && globalCaps
|
|
657
1149
|
return useQuery<Capabilities>({
|
|
658
1150
|
queryKey: ['capabilities', namespace],
|
|
659
1151
|
queryFn: () => fetchJSON(`/capabilities?namespace=${encodeURIComponent(namespace!)}`),
|
|
@@ -673,6 +1165,10 @@ export interface AuthMe {
|
|
|
673
1165
|
/** Pre-computed Cloud tier from `cloud:<tier>` group prefix.
|
|
674
1166
|
* Absent when not running under Cloud (OSS, OIDC, no role group). */
|
|
675
1167
|
cloudRole?: CloudRole
|
|
1168
|
+
/** Proxy mode only: whether an upstream sign-out URL is configured.
|
|
1169
|
+
* When false, logout clears Radar's cookie but the proxy may re-auth
|
|
1170
|
+
* the same user on the next request. */
|
|
1171
|
+
proxyLogoutConfigured?: boolean
|
|
676
1172
|
}
|
|
677
1173
|
|
|
678
1174
|
export function useAuthMe() {
|
|
@@ -684,10 +1180,14 @@ export function useAuthMe() {
|
|
|
684
1180
|
}
|
|
685
1181
|
|
|
686
1182
|
// Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
|
|
687
|
-
// CloudRole.AtLeast — the
|
|
1183
|
+
// CloudRole.AtLeast — the frontend must agree with the backend on what
|
|
688
1184
|
// "member-or-higher" means; otherwise we'd hide a button the
|
|
689
1185
|
// backend would happily honor (or vice versa).
|
|
690
|
-
const CLOUD_ROLE_RANK: Record<string, number> = {
|
|
1186
|
+
const CLOUD_ROLE_RANK: Record<string, number> = {
|
|
1187
|
+
viewer: 1,
|
|
1188
|
+
member: 2,
|
|
1189
|
+
owner: 3,
|
|
1190
|
+
}
|
|
691
1191
|
|
|
692
1192
|
/**
|
|
693
1193
|
* useCloudRole returns the caller's Cloud tier (`owner` / `member` /
|
|
@@ -768,23 +1268,175 @@ export function useNamespaces() {
|
|
|
768
1268
|
}
|
|
769
1269
|
|
|
770
1270
|
// Topology (for manual refresh)
|
|
771
|
-
export function useTopology(
|
|
1271
|
+
export function useTopology(
|
|
1272
|
+
namespaces: string[],
|
|
1273
|
+
viewMode: string = 'resources',
|
|
1274
|
+
options?: {
|
|
1275
|
+
enabled?: boolean
|
|
1276
|
+
includeReplicaSets?: boolean
|
|
1277
|
+
refetchInterval?: number | false
|
|
1278
|
+
},
|
|
1279
|
+
) {
|
|
772
1280
|
const params = new URLSearchParams()
|
|
773
1281
|
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
774
1282
|
if (viewMode) params.set('view', viewMode)
|
|
1283
|
+
if (options?.includeReplicaSets) params.set('includeReplicaSets', 'true')
|
|
775
1284
|
const queryString = params.toString()
|
|
776
1285
|
|
|
777
1286
|
return useQuery<Topology>({
|
|
778
|
-
queryKey: ['topology', namespaces, viewMode],
|
|
1287
|
+
queryKey: ['topology', namespaces, viewMode, options?.includeReplicaSets ?? false],
|
|
779
1288
|
queryFn: () => fetchJSON(`/topology${queryString ? `?${queryString}` : ''}`),
|
|
780
1289
|
staleTime: 5000, // 5 seconds
|
|
781
1290
|
enabled: options?.enabled !== false,
|
|
1291
|
+
refetchInterval: options?.refetchInterval,
|
|
1292
|
+
})
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
export function useApplications(namespaces: string[], options?: { enabled?: boolean }) {
|
|
1296
|
+
const params = new URLSearchParams()
|
|
1297
|
+
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
1298
|
+
const queryString = params.toString()
|
|
1299
|
+
|
|
1300
|
+
const enabled = options?.enabled !== false
|
|
1301
|
+
return useQuery<{ applications: AppRow[] }>({
|
|
1302
|
+
queryKey: ['applications', namespaces],
|
|
1303
|
+
queryFn: () => fetchJSON(`/applications${queryString ? `?${queryString}` : ''}`),
|
|
1304
|
+
staleTime: 30_000,
|
|
1305
|
+
// Only poll while a consumer needs the index; gated off it must not keep the
|
|
1306
|
+
// background refetch alive.
|
|
1307
|
+
enabled,
|
|
1308
|
+
refetchInterval: enabled ? APPLICATIONS_REFRESH_INTERVAL_MS : false,
|
|
1309
|
+
})
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
export function useApplicationHistory(
|
|
1313
|
+
appKey: string | undefined,
|
|
1314
|
+
namespaces: string[],
|
|
1315
|
+
options?: { enabled?: boolean },
|
|
1316
|
+
) {
|
|
1317
|
+
const params = new URLSearchParams()
|
|
1318
|
+
if (appKey) params.set('app', appKey)
|
|
1319
|
+
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
1320
|
+
const queryString = params.toString()
|
|
1321
|
+
|
|
1322
|
+
return useQuery<AppHistory>({
|
|
1323
|
+
queryKey: ['application-history', appKey, namespaces],
|
|
1324
|
+
queryFn: appKey ? () => fetchJSON(`/applications/history?${queryString}`) : skipToken,
|
|
1325
|
+
enabled: Boolean(appKey) && (options?.enabled ?? true),
|
|
1326
|
+
staleTime: 15_000,
|
|
1327
|
+
refetchInterval: APPLICATIONS_REFRESH_INTERVAL_MS,
|
|
1328
|
+
})
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
export function useGitOpsTree(
|
|
1332
|
+
kind: string,
|
|
1333
|
+
namespace: string,
|
|
1334
|
+
name: string,
|
|
1335
|
+
group?: string,
|
|
1336
|
+
namespaces: string[] = [],
|
|
1337
|
+
options?: { enabled?: boolean },
|
|
1338
|
+
) {
|
|
1339
|
+
const ns = namespace || '_'
|
|
1340
|
+
const params = new URLSearchParams()
|
|
1341
|
+
if (group) params.set('group', group)
|
|
1342
|
+
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
1343
|
+
const queryString = params.toString()
|
|
1344
|
+
|
|
1345
|
+
return useQuery<GitOpsResourceTree>({
|
|
1346
|
+
queryKey: ['gitops-tree', kind, namespace, name, group, namespaces],
|
|
1347
|
+
queryFn: () =>
|
|
1348
|
+
fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
|
|
1349
|
+
enabled: Boolean(kind && name) && (options?.enabled ?? true),
|
|
1350
|
+
staleTime: 5000,
|
|
1351
|
+
})
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// Poll fast (2s) while a sync/rollback is in flight so the user sees the
|
|
1355
|
+
// outcome quickly; otherwise rely on staleTime + manual refetch. Argo flips
|
|
1356
|
+
// operationState.phase from Running/Terminating to a terminal phase, so this
|
|
1357
|
+
// auto-quiesces on completion.
|
|
1358
|
+
const INSIGHTS_RUNNING_POLL_MS = 2000
|
|
1359
|
+
|
|
1360
|
+
export function useGitOpsInsights(
|
|
1361
|
+
kind: string,
|
|
1362
|
+
namespace: string,
|
|
1363
|
+
name: string,
|
|
1364
|
+
group?: string,
|
|
1365
|
+
namespaces: string[] = [],
|
|
1366
|
+
) {
|
|
1367
|
+
const ns = namespace || '_'
|
|
1368
|
+
const params = new URLSearchParams()
|
|
1369
|
+
if (group) params.set('group', group)
|
|
1370
|
+
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
1371
|
+
const queryString = params.toString()
|
|
1372
|
+
|
|
1373
|
+
return useQuery<GitOpsInsight>({
|
|
1374
|
+
queryKey: ['gitops-insights', kind, namespace, name, group, namespaces],
|
|
1375
|
+
queryFn: () =>
|
|
1376
|
+
fetchJSON(`/gitops/insights/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
|
|
1377
|
+
enabled: Boolean(kind && name),
|
|
1378
|
+
staleTime: 5000,
|
|
1379
|
+
refetchInterval: (query) => {
|
|
1380
|
+
const phase = query.state.data?.summary?.operationPhase
|
|
1381
|
+
return phase === 'Running' || phase === 'Terminating' ? INSIGHTS_RUNNING_POLL_MS : false
|
|
1382
|
+
},
|
|
1383
|
+
})
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Full Git-rendered desired-vs-live diff for one Argo CD managed resource.
|
|
1387
|
+
// ns/name identify the Application; the ref identifies the managed resource.
|
|
1388
|
+
// Fetched on demand — the caller mounts this only when the user opens "Full
|
|
1389
|
+
// diff", so it's enabled whenever the ref is resolvable. Errors surface via
|
|
1390
|
+
// fetchJSON's ApiError (server {"error"} string as .message).
|
|
1391
|
+
export function useArgoResourceDiff(appNamespace: string, appName: string, ref: GitOpsInsightRef) {
|
|
1392
|
+
const ns = appNamespace || '_'
|
|
1393
|
+
const params = new URLSearchParams()
|
|
1394
|
+
if (ref.group) params.set('group', ref.group)
|
|
1395
|
+
params.set('kind', ref.kind)
|
|
1396
|
+
if (ref.namespace) params.set('resourceNamespace', ref.namespace)
|
|
1397
|
+
params.set('resourceName', ref.name)
|
|
1398
|
+
|
|
1399
|
+
return useQuery<GitOpsResourceDiff>({
|
|
1400
|
+
queryKey: ['argo-resource-diff', appNamespace, appName, ref.group, ref.kind, ref.namespace, ref.name],
|
|
1401
|
+
queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/resource-diff?${params.toString()}`),
|
|
1402
|
+
enabled: Boolean(appName && ref.kind && ref.name),
|
|
1403
|
+
staleTime: 15_000,
|
|
1404
|
+
})
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// Git commit metadata for one deployed revision of an Argo CD Application.
|
|
1408
|
+
// Enabled only when a revision is known and the caller passes `enabled` (gated
|
|
1409
|
+
// on capabilities.revisionMetadataAvailable). Cached long — a resolved SHA's
|
|
1410
|
+
// metadata is effectively immutable.
|
|
1411
|
+
export function useArgoRevisionMetadata(
|
|
1412
|
+
appNamespace: string,
|
|
1413
|
+
appName: string,
|
|
1414
|
+
revision: string | undefined,
|
|
1415
|
+
opts?: { sourceIndex?: number; project?: string; enabled?: boolean },
|
|
1416
|
+
) {
|
|
1417
|
+
const ns = appNamespace || '_'
|
|
1418
|
+
const params = new URLSearchParams()
|
|
1419
|
+
if (revision) params.set('revision', revision)
|
|
1420
|
+
if (opts?.sourceIndex != null) params.set('sourceIndex', String(opts.sourceIndex))
|
|
1421
|
+
if (opts?.project) params.set('project', opts.project)
|
|
1422
|
+
|
|
1423
|
+
return useQuery<ArgoRevisionMetadata>({
|
|
1424
|
+
queryKey: ['argo-revision-metadata', appNamespace, appName, revision, opts?.sourceIndex, opts?.project],
|
|
1425
|
+
queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/revision-metadata?${params.toString()}`),
|
|
1426
|
+
enabled: Boolean(appName && revision) && (opts?.enabled ?? true),
|
|
1427
|
+
staleTime: 5 * 60_000,
|
|
782
1428
|
})
|
|
783
1429
|
}
|
|
784
1430
|
|
|
785
1431
|
// Generic resource fetching - returns resource with relationships
|
|
786
1432
|
// Uses '_' as placeholder for cluster-scoped resources (empty namespace)
|
|
787
|
-
export function useResource<T>(
|
|
1433
|
+
export function useResource<T>(
|
|
1434
|
+
kind: string,
|
|
1435
|
+
namespace: string,
|
|
1436
|
+
name: string,
|
|
1437
|
+
group?: string,
|
|
1438
|
+
options?: { enabled?: boolean; refetchInterval?: number | false },
|
|
1439
|
+
) {
|
|
788
1440
|
// For cluster-scoped resources, use '_' as namespace placeholder
|
|
789
1441
|
const ns = namespace || '_'
|
|
790
1442
|
const params = new URLSearchParams()
|
|
@@ -793,8 +1445,10 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
|
|
|
793
1445
|
|
|
794
1446
|
const query = useQuery<ResourceWithRelationships<T>>({
|
|
795
1447
|
queryKey: ['resource', kind, namespace, name, group],
|
|
796
|
-
queryFn: () =>
|
|
797
|
-
|
|
1448
|
+
queryFn: () =>
|
|
1449
|
+
fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
|
|
1450
|
+
enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
|
|
1451
|
+
refetchInterval: options?.refetchInterval,
|
|
798
1452
|
})
|
|
799
1453
|
|
|
800
1454
|
// Extract resource and relationships from the response
|
|
@@ -803,11 +1457,17 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
|
|
|
803
1457
|
data: query.data?.resource,
|
|
804
1458
|
relationships: query.data?.relationships,
|
|
805
1459
|
certificateInfo: query.data?.certificateInfo,
|
|
1460
|
+
hpaDiagnosis: query.data?.hpaDiagnosis,
|
|
806
1461
|
}
|
|
807
1462
|
}
|
|
808
1463
|
|
|
809
1464
|
// Hook that returns full response with relationships explicitly
|
|
810
|
-
export function useResourceWithRelationships<T>(
|
|
1465
|
+
export function useResourceWithRelationships<T>(
|
|
1466
|
+
kind: string,
|
|
1467
|
+
namespace: string,
|
|
1468
|
+
name: string,
|
|
1469
|
+
group?: string,
|
|
1470
|
+
) {
|
|
811
1471
|
const ns = namespace || '_'
|
|
812
1472
|
const params = new URLSearchParams()
|
|
813
1473
|
if (group) params.set('group', group)
|
|
@@ -815,13 +1475,19 @@ export function useResourceWithRelationships<T>(kind: string, namespace: string,
|
|
|
815
1475
|
|
|
816
1476
|
return useQuery<ResourceWithRelationships<T>>({
|
|
817
1477
|
queryKey: ['resource', kind, namespace, name, group],
|
|
818
|
-
queryFn: () =>
|
|
1478
|
+
queryFn: () =>
|
|
1479
|
+
fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
|
|
819
1480
|
enabled: Boolean(kind && name),
|
|
820
1481
|
})
|
|
821
1482
|
}
|
|
822
1483
|
|
|
823
1484
|
// List resources - queryKey includes group for cache sharing with ResourcesView
|
|
824
|
-
export function useResources<T>(
|
|
1485
|
+
export function useResources<T>(
|
|
1486
|
+
kind: string,
|
|
1487
|
+
namespace?: string,
|
|
1488
|
+
group?: string,
|
|
1489
|
+
options?: { enabled?: boolean; refetchInterval?: number | false },
|
|
1490
|
+
) {
|
|
825
1491
|
const params = new URLSearchParams()
|
|
826
1492
|
if (namespace) params.set('namespace', namespace)
|
|
827
1493
|
if (group) params.set('group', group)
|
|
@@ -830,20 +1496,153 @@ export function useResources<T>(kind: string, namespace?: string, group?: string
|
|
|
830
1496
|
return useQuery<T[]>({
|
|
831
1497
|
queryKey: ['resources', kind, group, namespace],
|
|
832
1498
|
queryFn: () => fetchJSON(`/resources/${kind}${queryString ? `?${queryString}` : ''}`),
|
|
1499
|
+
enabled: (options?.enabled ?? true) && Boolean(kind),
|
|
833
1500
|
staleTime: 30000, // 30 seconds - matches refetchInterval in ResourcesView
|
|
1501
|
+
refetchInterval: options?.refetchInterval,
|
|
834
1502
|
})
|
|
835
1503
|
}
|
|
836
1504
|
|
|
837
1505
|
// Timeline changes (unified view of changes + K8s events)
|
|
838
1506
|
export interface UseChangesOptions {
|
|
839
1507
|
namespaces?: string[]
|
|
840
|
-
kind
|
|
1508
|
+
// Kind filter. The server narrows to a single kind (tighter result caps), so
|
|
1509
|
+
// exactly one selected kind is pushed server-side; a multi-kind selection
|
|
1510
|
+
// fetches unfiltered and is narrowed client-side by the caller.
|
|
1511
|
+
kinds?: string[]
|
|
841
1512
|
timeRange?: TimeRange
|
|
842
1513
|
filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
|
|
843
1514
|
includeK8sEvents?: boolean
|
|
844
1515
|
includeManaged?: boolean
|
|
1516
|
+
includeDeleted?: boolean
|
|
845
1517
|
limit?: number
|
|
846
1518
|
enabled?: boolean
|
|
1519
|
+
// Cursor-aware refetches: after the first full load, refetches ask the
|
|
1520
|
+
// server only for events that arrived after the highest seq already cached
|
|
1521
|
+
// and merge them in, instead of re-pulling the whole ring. Intended for the
|
|
1522
|
+
// timeline's full-ring (10k) query, where every SSE nudge would otherwise
|
|
1523
|
+
// re-transfer megabytes for a handful of new events.
|
|
1524
|
+
deltaSync?: boolean
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// The store epoch guards delta cursors: a restarted store restarts seq
|
|
1528
|
+
// numbering, so an epoch change forces a full resync. A periodic full resync
|
|
1529
|
+
// also runs as anti-entropy for anything a dropped SSE connection or a
|
|
1530
|
+
// server-side eviction could leave behind in the cached copy.
|
|
1531
|
+
const FULL_RESYNC_MS = 5 * 60_000
|
|
1532
|
+
|
|
1533
|
+
export interface ChangesDeltaMeta {
|
|
1534
|
+
epoch: string
|
|
1535
|
+
lastFullMs: number
|
|
1536
|
+
// Highest seq observed in ANY response for this query — not just what
|
|
1537
|
+
// survived the cap. A delta event older than everything cached gets capped
|
|
1538
|
+
// out of the merge; deriving the cursor from cached rows alone would then
|
|
1539
|
+
// re-request that same event on every refetch until the next full resync.
|
|
1540
|
+
highWaterSeq: number
|
|
1541
|
+
}
|
|
1542
|
+
const changesDeltaMeta = new Map<string, ChangesDeltaMeta>()
|
|
1543
|
+
|
|
1544
|
+
// The since_seq cursor for the next refetch, or 0 for a full fetch. Delta
|
|
1545
|
+
// requires an epoch-stamped prior full load, a cached page to merge into, and
|
|
1546
|
+
// the anti-entropy full resync not being due.
|
|
1547
|
+
export function deltaFetchCursor(
|
|
1548
|
+
meta: ChangesDeltaMeta | undefined,
|
|
1549
|
+
cached: TimelineEvent[] | undefined,
|
|
1550
|
+
nowMs: number,
|
|
1551
|
+
): number {
|
|
1552
|
+
if (!meta?.epoch || !cached) return 0
|
|
1553
|
+
if (nowMs - meta.lastFullMs > FULL_RESYNC_MS) return 0
|
|
1554
|
+
return Math.max(meta.highWaterSeq, maxEventSeq(cached))
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
async function fetchChangesPage(
|
|
1558
|
+
path: string,
|
|
1559
|
+
signal?: AbortSignal,
|
|
1560
|
+
): Promise<{ events: TimelineEvent[]; epoch: string; maxSeq: number }> {
|
|
1561
|
+
const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined)
|
|
1562
|
+
if (!response.ok) {
|
|
1563
|
+
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1564
|
+
throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
|
|
1565
|
+
}
|
|
1566
|
+
const events = (await response.json()) as TimelineEvent[]
|
|
1567
|
+
// maxSeq is the page's frontier computed before the server's
|
|
1568
|
+
// cluster-scoped-RBAC filter — rows dropped THERE still advance the cursor.
|
|
1569
|
+
// (Rows dropped by content filters inside the store query do not; see the
|
|
1570
|
+
// known limitation on the server's handleChanges.)
|
|
1571
|
+
const maxSeq = Number(response.headers.get('X-Radar-Timeline-Max-Seq') ?? '0') || 0
|
|
1572
|
+
return {
|
|
1573
|
+
events,
|
|
1574
|
+
epoch: response.headers.get('X-Radar-Timeline-Epoch') ?? '',
|
|
1575
|
+
maxSeq,
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Highest store-assigned arrival number in the cached page — the delta cursor.
|
|
1580
|
+
export function maxEventSeq(events: TimelineEvent[]): number {
|
|
1581
|
+
let max = 0
|
|
1582
|
+
for (const event of events) {
|
|
1583
|
+
if (event.seq && event.seq > max) max = event.seq
|
|
1584
|
+
}
|
|
1585
|
+
return max
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// Merge a delta page into the cached page: a delta row replaces its cached id
|
|
1589
|
+
// (a K8s Event count bump re-arrives under the same id), new ids are added,
|
|
1590
|
+
// order stays newest-first (arrival number breaks timestamp ties), and the
|
|
1591
|
+
// result is capped to the query's limit by dropping the oldest.
|
|
1592
|
+
export function mergeDeltaEvents(
|
|
1593
|
+
prev: TimelineEvent[],
|
|
1594
|
+
delta: TimelineEvent[],
|
|
1595
|
+
cap: number,
|
|
1596
|
+
): TimelineEvent[] {
|
|
1597
|
+
if (delta.length === 0) return prev
|
|
1598
|
+
const replaced = new Set(delta.map((event) => event.id))
|
|
1599
|
+
const merged = [...delta, ...prev.filter((event) => !replaced.has(event.id))]
|
|
1600
|
+
merged.sort((a, b) => {
|
|
1601
|
+
const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
1602
|
+
if (byTime !== 0) return byTime
|
|
1603
|
+
return (b.seq ?? 0) - (a.seq ?? 0)
|
|
1604
|
+
})
|
|
1605
|
+
return merged.length > cap ? merged.slice(0, cap) : merged
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// Delta-sync orchestration for useChanges, extracted so the
|
|
1609
|
+
// full-fetch → delta-poll → epoch-mismatch-resync contract is exercisable
|
|
1610
|
+
// without a React render. State is passed in explicitly — the cached page and
|
|
1611
|
+
// the shared meta store — rather than closed over from module scope, so a
|
|
1612
|
+
// caller (and a test) drives it with fresh state each invocation.
|
|
1613
|
+
export async function runDeltaSyncFetch(deps: {
|
|
1614
|
+
path: string
|
|
1615
|
+
queryString: string
|
|
1616
|
+
limit: number
|
|
1617
|
+
metaKey: string
|
|
1618
|
+
cached: TimelineEvent[] | undefined
|
|
1619
|
+
metaStore: Map<string, ChangesDeltaMeta>
|
|
1620
|
+
now: number
|
|
1621
|
+
signal?: AbortSignal
|
|
1622
|
+
}): Promise<TimelineEvent[]> {
|
|
1623
|
+
const { path, queryString, limit, metaKey, cached, metaStore, now, signal } = deps
|
|
1624
|
+
const meta = metaStore.get(metaKey)
|
|
1625
|
+
const cursor = deltaFetchCursor(meta, cached, now)
|
|
1626
|
+
if (cursor > 0) {
|
|
1627
|
+
const delta = await fetchChangesPage(
|
|
1628
|
+
`${path}${queryString ? '&' : '?'}since_seq=${cursor}`,
|
|
1629
|
+
signal,
|
|
1630
|
+
)
|
|
1631
|
+
if (delta.epoch && delta.epoch === meta!.epoch) {
|
|
1632
|
+
meta!.highWaterSeq = Math.max(meta!.highWaterSeq, delta.maxSeq, maxEventSeq(delta.events))
|
|
1633
|
+
// Returning the cached reference on an empty delta skips re-renders.
|
|
1634
|
+
return delta.events.length ? mergeDeltaEvents(cached!, delta.events, limit) : cached!
|
|
1635
|
+
}
|
|
1636
|
+
// Epoch changed — the store restarted and seq numbering reset, so the
|
|
1637
|
+
// cursor is meaningless. Fall through to a full resync.
|
|
1638
|
+
}
|
|
1639
|
+
const full = await fetchChangesPage(path, signal)
|
|
1640
|
+
metaStore.set(metaKey, {
|
|
1641
|
+
epoch: full.epoch,
|
|
1642
|
+
lastFullMs: now,
|
|
1643
|
+
highWaterSeq: Math.max(full.maxSeq, maxEventSeq(full.events)),
|
|
1644
|
+
})
|
|
1645
|
+
return full.events
|
|
847
1646
|
}
|
|
848
1647
|
|
|
849
1648
|
function getTimeRangeDate(range: TimeRange): Date | null {
|
|
@@ -860,20 +1659,41 @@ function getTimeRangeDate(range: TimeRange): Date | null {
|
|
|
860
1659
|
return new Date(now.getTime() - 6 * 60 * 60 * 1000)
|
|
861
1660
|
case '24h':
|
|
862
1661
|
return new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
|
1662
|
+
case '7d':
|
|
1663
|
+
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
|
1664
|
+
case '30d':
|
|
1665
|
+
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
|
863
1666
|
default:
|
|
864
1667
|
return null
|
|
865
1668
|
}
|
|
866
1669
|
}
|
|
867
1670
|
|
|
868
1671
|
export function useChanges(options: UseChangesOptions = {}) {
|
|
869
|
-
const {
|
|
1672
|
+
const {
|
|
1673
|
+
namespaces = [],
|
|
1674
|
+
kinds,
|
|
1675
|
+
timeRange = '1h',
|
|
1676
|
+
filter = 'all',
|
|
1677
|
+
includeK8sEvents = true,
|
|
1678
|
+
includeManaged = false,
|
|
1679
|
+
includeDeleted = true,
|
|
1680
|
+
limit = 200,
|
|
1681
|
+
enabled = true,
|
|
1682
|
+
deltaSync = false,
|
|
1683
|
+
} = options
|
|
1684
|
+
const queryClient = useQueryClient()
|
|
1685
|
+
|
|
1686
|
+
// Only a single-kind selection narrows the server query; a multi-kind
|
|
1687
|
+
// selection is filtered client-side so the server cap isn't spent on one kind.
|
|
1688
|
+
const serverKind = kinds && kinds.length === 1 ? kinds[0] : undefined
|
|
870
1689
|
|
|
871
1690
|
const params = new URLSearchParams()
|
|
872
1691
|
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
873
|
-
if (
|
|
1692
|
+
if (serverKind) params.set('kind', serverKind)
|
|
874
1693
|
if (filter) params.set('filter', filter)
|
|
875
1694
|
if (!includeK8sEvents) params.set('include_k8s_events', 'false')
|
|
876
1695
|
if (includeManaged) params.set('include_managed', 'true')
|
|
1696
|
+
if (!includeDeleted) params.set('include_deleted', 'false')
|
|
877
1697
|
params.set('limit', String(limit))
|
|
878
1698
|
|
|
879
1699
|
const sinceDate = getTimeRangeDate(timeRange)
|
|
@@ -882,18 +1702,50 @@ export function useChanges(options: UseChangesOptions = {}) {
|
|
|
882
1702
|
}
|
|
883
1703
|
|
|
884
1704
|
const queryString = params.toString()
|
|
1705
|
+
const path = `/changes${queryString ? `?${queryString}` : ''}`
|
|
1706
|
+
const queryKey = [
|
|
1707
|
+
'changes',
|
|
1708
|
+
namespaces,
|
|
1709
|
+
serverKind,
|
|
1710
|
+
timeRange,
|
|
1711
|
+
filter,
|
|
1712
|
+
includeK8sEvents,
|
|
1713
|
+
includeManaged,
|
|
1714
|
+
includeDeleted,
|
|
1715
|
+
limit,
|
|
1716
|
+
]
|
|
885
1717
|
|
|
886
1718
|
return useQuery<TimelineEvent[]>({
|
|
887
|
-
queryKey
|
|
888
|
-
queryFn:
|
|
889
|
-
|
|
890
|
-
|
|
1719
|
+
queryKey,
|
|
1720
|
+
queryFn: async ({ signal }) => {
|
|
1721
|
+
if (!deltaSync) return fetchJSON(path, signal)
|
|
1722
|
+
|
|
1723
|
+
const metaKey = JSON.stringify(queryKey)
|
|
1724
|
+
const cached = queryClient.getQueryData<TimelineEvent[]>(queryKey)
|
|
1725
|
+
return runDeltaSyncFetch({
|
|
1726
|
+
path,
|
|
1727
|
+
queryString,
|
|
1728
|
+
limit,
|
|
1729
|
+
metaKey,
|
|
1730
|
+
cached,
|
|
1731
|
+
metaStore: changesDeltaMeta,
|
|
1732
|
+
now: Date.now(),
|
|
1733
|
+
signal,
|
|
1734
|
+
})
|
|
1735
|
+
},
|
|
1736
|
+
staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
|
|
1737
|
+
refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE-driven invalidation handles real-time updates; this is the no-SSE fallback
|
|
891
1738
|
enabled,
|
|
892
1739
|
})
|
|
893
1740
|
}
|
|
894
1741
|
|
|
895
1742
|
// Children changes for a parent workload (e.g., ReplicaSets and Pods under a Deployment)
|
|
896
|
-
export function useResourceChildren(
|
|
1743
|
+
export function useResourceChildren(
|
|
1744
|
+
kind: string,
|
|
1745
|
+
namespace: string,
|
|
1746
|
+
name: string,
|
|
1747
|
+
timeRange: TimeRange = '1h',
|
|
1748
|
+
) {
|
|
897
1749
|
const sinceDate = getTimeRangeDate(timeRange)
|
|
898
1750
|
const params = new URLSearchParams()
|
|
899
1751
|
if (sinceDate) {
|
|
@@ -922,7 +1774,11 @@ export interface ResourceEventsResult {
|
|
|
922
1774
|
// K8s events and resource updates are fetched separately so a high-frequency
|
|
923
1775
|
// informer update stream (e.g. a CrashLoop status field flapping every few
|
|
924
1776
|
// seconds) can never starve out user-meaningful K8s events under a shared limit.
|
|
925
|
-
export function useResourceEvents(
|
|
1777
|
+
export function useResourceEvents(
|
|
1778
|
+
kind: string,
|
|
1779
|
+
namespace: string,
|
|
1780
|
+
name: string,
|
|
1781
|
+
): ResourceEventsResult {
|
|
926
1782
|
// The timeline store keys events by their K8s Kind (singular PascalCase, e.g. "Pod"),
|
|
927
1783
|
// but callers pass the URL-form kind ("pods").
|
|
928
1784
|
const singularKind = pluralToKind(kind)
|
|
@@ -935,6 +1791,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
|
|
|
935
1791
|
const p = new URLSearchParams()
|
|
936
1792
|
p.set('namespace', namespace)
|
|
937
1793
|
p.set('kind', singularKind)
|
|
1794
|
+
p.set('name', name)
|
|
938
1795
|
p.set('include_managed', 'true')
|
|
939
1796
|
p.set('since', since)
|
|
940
1797
|
return p
|
|
@@ -951,8 +1808,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
|
|
|
951
1808
|
const params = baseParams()
|
|
952
1809
|
params.set('sources', 'k8s_event')
|
|
953
1810
|
params.set('limit', '500')
|
|
954
|
-
|
|
955
|
-
return events.filter(e => e.name === name)
|
|
1811
|
+
return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
|
|
956
1812
|
},
|
|
957
1813
|
enabled,
|
|
958
1814
|
refetchInterval: 15000,
|
|
@@ -966,8 +1822,7 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
|
|
|
966
1822
|
const params = baseParams()
|
|
967
1823
|
params.set('sources', 'informer,historical')
|
|
968
1824
|
params.set('limit', '50')
|
|
969
|
-
|
|
970
|
-
return events.filter(e => e.name === name)
|
|
1825
|
+
return fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
|
|
971
1826
|
},
|
|
972
1827
|
enabled,
|
|
973
1828
|
refetchInterval: 15000,
|
|
@@ -989,8 +1844,8 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
|
|
|
989
1844
|
export interface ContainerMetrics {
|
|
990
1845
|
name: string
|
|
991
1846
|
usage: {
|
|
992
|
-
cpu: string
|
|
993
|
-
memory: string
|
|
1847
|
+
cpu: string // e.g., "10m" (millicores)
|
|
1848
|
+
memory: string // e.g., "128Mi"
|
|
994
1849
|
}
|
|
995
1850
|
}
|
|
996
1851
|
|
|
@@ -1018,25 +1873,44 @@ export interface NodeMetrics {
|
|
|
1018
1873
|
}
|
|
1019
1874
|
}
|
|
1020
1875
|
|
|
1876
|
+
async function fetchMetricsOrNull<T>(path: string): Promise<T | null> {
|
|
1877
|
+
try {
|
|
1878
|
+
return await fetchJSON<T>(path)
|
|
1879
|
+
} catch (error) {
|
|
1880
|
+
if (isMetricsUnavailableError(error)) return null
|
|
1881
|
+
throw error
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function retryMetricsQuery(failureCount: number, error: unknown): boolean {
|
|
1886
|
+
return !isMetricsUnavailableError(error) && failureCount < 1
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1021
1889
|
// Fetch metrics for a specific pod
|
|
1022
|
-
export function usePodMetrics(namespace: string, podName: string) {
|
|
1023
|
-
return useQuery<PodMetrics>({
|
|
1890
|
+
export function usePodMetrics(namespace: string, podName: string, options?: { enabled?: boolean }) {
|
|
1891
|
+
return useQuery<PodMetrics | null>({
|
|
1024
1892
|
queryKey: ['pod-metrics', namespace, podName],
|
|
1025
|
-
queryFn: () =>
|
|
1026
|
-
enabled: Boolean(namespace && podName),
|
|
1027
|
-
staleTime: 15000,
|
|
1028
|
-
refetchInterval: 30000,
|
|
1893
|
+
queryFn: () => fetchMetricsOrNull<PodMetrics>(`/metrics/pods/${namespace}/${podName}`),
|
|
1894
|
+
enabled: Boolean(namespace && podName) && (options?.enabled ?? true),
|
|
1895
|
+
staleTime: 15000,
|
|
1896
|
+
refetchInterval: 30000,
|
|
1897
|
+
refetchOnMount: 'always',
|
|
1898
|
+
refetchOnReconnect: 'always',
|
|
1899
|
+
retry: retryMetricsQuery,
|
|
1029
1900
|
})
|
|
1030
1901
|
}
|
|
1031
1902
|
|
|
1032
1903
|
// Fetch metrics for a specific node
|
|
1033
|
-
export function useNodeMetrics(nodeName: string) {
|
|
1034
|
-
return useQuery<NodeMetrics>({
|
|
1904
|
+
export function useNodeMetrics(nodeName: string, options?: { enabled?: boolean }) {
|
|
1905
|
+
return useQuery<NodeMetrics | null>({
|
|
1035
1906
|
queryKey: ['node-metrics', nodeName],
|
|
1036
|
-
queryFn: () =>
|
|
1037
|
-
enabled: Boolean(nodeName),
|
|
1907
|
+
queryFn: () => fetchMetricsOrNull<NodeMetrics>(`/metrics/nodes/${nodeName}`),
|
|
1908
|
+
enabled: Boolean(nodeName) && (options?.enabled ?? true),
|
|
1038
1909
|
staleTime: 15000,
|
|
1039
1910
|
refetchInterval: 30000,
|
|
1911
|
+
refetchOnMount: 'always',
|
|
1912
|
+
refetchOnReconnect: 'always',
|
|
1913
|
+
retry: retryMetricsQuery,
|
|
1040
1914
|
})
|
|
1041
1915
|
}
|
|
1042
1916
|
|
|
@@ -1046,8 +1920,8 @@ export function useNodeMetrics(nodeName: string) {
|
|
|
1046
1920
|
|
|
1047
1921
|
export interface MetricsDataPoint {
|
|
1048
1922
|
timestamp: string
|
|
1049
|
-
cpu: number
|
|
1050
|
-
memory: number
|
|
1923
|
+
cpu: number // CPU in nanocores
|
|
1924
|
+
memory: number // Memory in bytes
|
|
1051
1925
|
}
|
|
1052
1926
|
|
|
1053
1927
|
export interface ContainerMetricsHistory {
|
|
@@ -1060,19 +1934,77 @@ export interface PodMetricsHistory {
|
|
|
1060
1934
|
name: string
|
|
1061
1935
|
containers: ContainerMetricsHistory[]
|
|
1062
1936
|
collectionError?: string
|
|
1937
|
+
rawCollectionError?: string
|
|
1938
|
+
metricsUnavailableDiagnosis?: string
|
|
1939
|
+
metricsUnavailable?: boolean
|
|
1940
|
+
metricsUnavailableReason?: string
|
|
1063
1941
|
}
|
|
1064
1942
|
|
|
1065
1943
|
export interface NodeMetricsHistory {
|
|
1066
1944
|
name: string
|
|
1067
1945
|
dataPoints: MetricsDataPoint[]
|
|
1068
1946
|
collectionError?: string
|
|
1947
|
+
rawCollectionError?: string
|
|
1948
|
+
metricsUnavailableDiagnosis?: string
|
|
1949
|
+
metricsUnavailable?: boolean
|
|
1950
|
+
metricsUnavailableReason?: string
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
function withoutCollectionError<
|
|
1954
|
+
T extends { collectionError?: string; rawCollectionError?: string },
|
|
1955
|
+
>(history: T): T {
|
|
1956
|
+
const next = { ...history }
|
|
1957
|
+
delete next.collectionError
|
|
1958
|
+
delete next.rawCollectionError
|
|
1959
|
+
return next
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
export function normalizePodMetricsHistory(history: PodMetricsHistory): PodMetricsHistory {
|
|
1963
|
+
if (history.metricsUnavailable !== true) return history
|
|
1964
|
+
return {
|
|
1965
|
+
...withoutCollectionError(history),
|
|
1966
|
+
metricsUnavailable: true,
|
|
1967
|
+
metricsUnavailableReason: history.rawCollectionError || history.collectionError,
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
export function normalizeNodeMetricsHistory(history: NodeMetricsHistory): NodeMetricsHistory {
|
|
1972
|
+
if (history.metricsUnavailable !== true) return history
|
|
1973
|
+
return {
|
|
1974
|
+
...withoutCollectionError(history),
|
|
1975
|
+
metricsUnavailable: true,
|
|
1976
|
+
metricsUnavailableReason: history.rawCollectionError || history.collectionError,
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
export function shouldFetchLiveMetrics(
|
|
1981
|
+
historySettled: boolean,
|
|
1982
|
+
metricsUnavailable: boolean,
|
|
1983
|
+
): boolean {
|
|
1984
|
+
return historySettled && !metricsUnavailable
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
export function isLiveMetricsUnavailable(liveMetricsEnabled: boolean, metrics: unknown): boolean {
|
|
1988
|
+
return liveMetricsEnabled && metrics === null
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
export function getVisibleLiveMetrics<T>(
|
|
1992
|
+
liveMetricsEnabled: boolean,
|
|
1993
|
+
metricsUnavailable: boolean,
|
|
1994
|
+
metrics: T | null | undefined,
|
|
1995
|
+
): T | undefined {
|
|
1996
|
+
if (!liveMetricsEnabled || metricsUnavailable) return undefined
|
|
1997
|
+
return metrics ?? undefined
|
|
1069
1998
|
}
|
|
1070
1999
|
|
|
1071
2000
|
// Fetch historical metrics for a pod (last ~1 hour)
|
|
1072
2001
|
export function usePodMetricsHistory(namespace: string, podName: string) {
|
|
1073
2002
|
return useQuery<PodMetricsHistory>({
|
|
1074
2003
|
queryKey: ['pod-metrics-history', namespace, podName],
|
|
1075
|
-
queryFn: () =>
|
|
2004
|
+
queryFn: async () =>
|
|
2005
|
+
normalizePodMetricsHistory(
|
|
2006
|
+
await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`),
|
|
2007
|
+
),
|
|
1076
2008
|
enabled: Boolean(namespace && podName),
|
|
1077
2009
|
staleTime: 25000, // Slightly less than poll interval
|
|
1078
2010
|
refetchInterval: 30000, // Match the backend poll interval
|
|
@@ -1083,7 +2015,10 @@ export function usePodMetricsHistory(namespace: string, podName: string) {
|
|
|
1083
2015
|
export function useNodeMetricsHistory(nodeName: string) {
|
|
1084
2016
|
return useQuery<NodeMetricsHistory>({
|
|
1085
2017
|
queryKey: ['node-metrics-history', nodeName],
|
|
1086
|
-
queryFn: () =>
|
|
2018
|
+
queryFn: async () =>
|
|
2019
|
+
normalizeNodeMetricsHistory(
|
|
2020
|
+
await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`),
|
|
2021
|
+
),
|
|
1087
2022
|
enabled: Boolean(nodeName),
|
|
1088
2023
|
staleTime: 25000,
|
|
1089
2024
|
refetchInterval: 30000,
|
|
@@ -1094,38 +2029,45 @@ export function useNodeMetricsHistory(nodeName: string) {
|
|
|
1094
2029
|
export interface TopPodMetrics {
|
|
1095
2030
|
namespace: string
|
|
1096
2031
|
name: string
|
|
1097
|
-
cpu: number
|
|
1098
|
-
memory: number
|
|
1099
|
-
cpuRequest: number
|
|
1100
|
-
cpuLimit: number
|
|
2032
|
+
cpu: number // nanocores (usage)
|
|
2033
|
+
memory: number // bytes (usage)
|
|
2034
|
+
cpuRequest: number // nanocores (sum across containers)
|
|
2035
|
+
cpuLimit: number // nanocores (sum across containers)
|
|
1101
2036
|
memoryRequest: number // bytes (sum across containers)
|
|
1102
|
-
memoryLimit: number
|
|
2037
|
+
memoryLimit: number // bytes (sum across containers)
|
|
1103
2038
|
}
|
|
1104
2039
|
|
|
1105
2040
|
export interface TopNodeMetrics {
|
|
1106
2041
|
name: string
|
|
1107
|
-
cpu: number
|
|
1108
|
-
memory: number
|
|
1109
|
-
podCount: number
|
|
1110
|
-
cpuAllocatable: number
|
|
2042
|
+
cpu: number // nanocores (usage)
|
|
2043
|
+
memory: number // bytes (usage)
|
|
2044
|
+
podCount: number // pods scheduled on this node
|
|
2045
|
+
cpuAllocatable: number // nanocores
|
|
1111
2046
|
memoryAllocatable: number // bytes
|
|
1112
2047
|
}
|
|
1113
2048
|
|
|
1114
|
-
// Fetch bulk metrics for
|
|
1115
|
-
export function useTopPodMetrics() {
|
|
2049
|
+
// Fetch bulk metrics for pods (for CPU/Memory columns in resource table)
|
|
2050
|
+
export function useTopPodMetrics(options?: { enabled?: boolean; namespaces?: string[] }) {
|
|
2051
|
+
const namespacesParam = options?.namespaces?.join(',') ?? ''
|
|
2052
|
+
const params = new URLSearchParams()
|
|
2053
|
+
if (namespacesParam) params.set('namespaces', namespacesParam)
|
|
2054
|
+
const queryString = params.toString()
|
|
2055
|
+
|
|
1116
2056
|
return useQuery<TopPodMetrics[]>({
|
|
1117
|
-
queryKey: ['top-pod-metrics'],
|
|
1118
|
-
queryFn: () => fetchJSON(
|
|
2057
|
+
queryKey: ['top-pod-metrics', namespacesParam],
|
|
2058
|
+
queryFn: () => fetchJSON(`/metrics/top/pods${queryString ? `?${queryString}` : ''}`),
|
|
2059
|
+
enabled: options?.enabled ?? true,
|
|
1119
2060
|
staleTime: 25000,
|
|
1120
2061
|
refetchInterval: 30000,
|
|
1121
2062
|
})
|
|
1122
2063
|
}
|
|
1123
2064
|
|
|
1124
2065
|
// Fetch bulk metrics for all nodes (for CPU/Memory columns in resource table)
|
|
1125
|
-
export function useTopNodeMetrics() {
|
|
2066
|
+
export function useTopNodeMetrics(options?: { enabled?: boolean }) {
|
|
1126
2067
|
return useQuery<TopNodeMetrics[]>({
|
|
1127
2068
|
queryKey: ['top-node-metrics'],
|
|
1128
2069
|
queryFn: () => fetchJSON('/metrics/top/nodes'),
|
|
2070
|
+
enabled: options?.enabled ?? true,
|
|
1129
2071
|
staleTime: 25000,
|
|
1130
2072
|
refetchInterval: 30000,
|
|
1131
2073
|
})
|
|
@@ -1150,19 +2092,21 @@ export interface PrometheusStatus {
|
|
|
1150
2092
|
error?: string
|
|
1151
2093
|
}
|
|
1152
2094
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
2095
|
+
// Time-series sample types live in @skyhook-io/k8s-ui (shared with library
|
|
2096
|
+
// consumers). Re-export here so radar-app callers keep their existing import
|
|
2097
|
+
// paths; the Prom-prefixed names are deprecated aliases.
|
|
2098
|
+
export type {
|
|
2099
|
+
TimeSeriesPoint,
|
|
2100
|
+
TimeSeries,
|
|
2101
|
+
PrometheusDataPoint,
|
|
2102
|
+
PrometheusSeries,
|
|
2103
|
+
} from '@skyhook-io/k8s-ui/components/charts'
|
|
1157
2104
|
|
|
1158
|
-
|
|
1159
|
-
labels: Record<string, string>
|
|
1160
|
-
dataPoints: PrometheusDataPoint[]
|
|
1161
|
-
}
|
|
2105
|
+
import type { TimeSeries as ChartTimeSeries } from '@skyhook-io/k8s-ui/components/charts'
|
|
1162
2106
|
|
|
1163
2107
|
export interface PrometheusQueryResult {
|
|
1164
2108
|
resultType: string
|
|
1165
|
-
series:
|
|
2109
|
+
series: ChartTimeSeries[]
|
|
1166
2110
|
}
|
|
1167
2111
|
|
|
1168
2112
|
export interface PrometheusResourceMetrics {
|
|
@@ -1174,11 +2118,115 @@ export interface PrometheusResourceMetrics {
|
|
|
1174
2118
|
range: string
|
|
1175
2119
|
result: PrometheusQueryResult
|
|
1176
2120
|
query?: string // PromQL query (included when result is empty, for diagnostics)
|
|
1177
|
-
hint?: string
|
|
2121
|
+
hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
export type PrometheusMetricCategory =
|
|
2125
|
+
'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
|
|
2126
|
+
export type PrometheusTimeRange =
|
|
2127
|
+
'10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
|
|
2128
|
+
|
|
2129
|
+
// PVC usage at a moment in time, derived from kubelet_volume_stats_*.
|
|
2130
|
+
// HasData=false silently indicates the CSI driver doesn't report or Prom
|
|
2131
|
+
// isn't scraping kubelet endpoints — UI should hide the gauge in that case.
|
|
2132
|
+
export interface PrometheusPVCUsage {
|
|
2133
|
+
namespace: string
|
|
2134
|
+
name: string
|
|
2135
|
+
used: number
|
|
2136
|
+
capacity: number
|
|
2137
|
+
ratio: number
|
|
2138
|
+
hasData: boolean
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
export type RightsizingFit =
|
|
2142
|
+
'balanced' | 'oversized' | 'under_requested' | 'missing_request' | 'insufficient_history'
|
|
2143
|
+
export type RightsizingConfidence = 'low' | 'medium' | 'high'
|
|
2144
|
+
export type RightsizingOwnerCoverage = 'ksm_history' | 'current_pods'
|
|
2145
|
+
|
|
2146
|
+
export interface RightsizingRow {
|
|
2147
|
+
container: string
|
|
2148
|
+
resource: 'cpu' | 'memory'
|
|
2149
|
+
fit: RightsizingFit
|
|
2150
|
+
confidence: RightsizingConfidence
|
|
2151
|
+
currentRequest?: string
|
|
2152
|
+
currentRequestValue?: number
|
|
2153
|
+
currentLimit?: string
|
|
2154
|
+
currentLimitValue?: number
|
|
2155
|
+
observed?: {
|
|
2156
|
+
name: 'P95' | 'P99' | 'Max'
|
|
2157
|
+
value: number
|
|
2158
|
+
formatted: string
|
|
2159
|
+
}
|
|
2160
|
+
peak?: {
|
|
2161
|
+
name: 'P99'
|
|
2162
|
+
value: number
|
|
2163
|
+
formatted: string
|
|
2164
|
+
}
|
|
2165
|
+
calculatedRequest?: string
|
|
2166
|
+
calculatedRequestValue?: number
|
|
2167
|
+
recommendedRequest?: string
|
|
2168
|
+
recommendedRequestValue?: number
|
|
2169
|
+
reductionLimited?: boolean
|
|
2170
|
+
bursty?: boolean
|
|
2171
|
+
recommendationReason?: string
|
|
2172
|
+
sampleCount: number
|
|
2173
|
+
expectedSamples: number
|
|
2174
|
+
coverage: number
|
|
2175
|
+
hpaManaged: boolean
|
|
2176
|
+
hpaEvidenceAvailable: boolean
|
|
2177
|
+
throttleAvailable?: boolean
|
|
2178
|
+
throttleRatio?: number
|
|
2179
|
+
currentPodOOM?: boolean
|
|
2180
|
+
windowOomEvidence?: boolean
|
|
2181
|
+
oomEvidenceAvailable: boolean
|
|
2182
|
+
limitConflict?: boolean
|
|
2183
|
+
queryError?: string
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
export interface PrometheusRightsizing {
|
|
2187
|
+
kind: string
|
|
2188
|
+
namespace: string
|
|
2189
|
+
name: string
|
|
2190
|
+
window: string
|
|
2191
|
+
source: 'radar'
|
|
2192
|
+
ownerCoverage: RightsizingOwnerCoverage
|
|
2193
|
+
scaledToZero: boolean
|
|
2194
|
+
sampleAvailable: boolean
|
|
2195
|
+
rows: RightsizingRow[]
|
|
2196
|
+
reason?: string
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
export type RightsizingScanState = 'complete' | 'partial' | 'unavailable'
|
|
2200
|
+
|
|
2201
|
+
export interface RightsizingScanWorkload {
|
|
2202
|
+
kind: string
|
|
2203
|
+
namespace: string
|
|
2204
|
+
name: string
|
|
2205
|
+
replicas: number
|
|
2206
|
+
scaledToZero: boolean
|
|
2207
|
+
rows: RightsizingRow[]
|
|
1178
2208
|
}
|
|
1179
2209
|
|
|
1180
|
-
export
|
|
1181
|
-
|
|
2210
|
+
export interface RightsizingScanCoverage {
|
|
2211
|
+
workloadsDiscovered: number
|
|
2212
|
+
workloadsEvaluated: number
|
|
2213
|
+
workloadsWithData: number
|
|
2214
|
+
batches: number
|
|
2215
|
+
completedBatches: number
|
|
2216
|
+
restrictedKinds?: string[]
|
|
2217
|
+
unavailableKinds?: string[]
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
export interface RightsizingScanResponse {
|
|
2221
|
+
state: RightsizingScanState
|
|
2222
|
+
scannedAt: string
|
|
2223
|
+
window: string
|
|
2224
|
+
source: 'radar'
|
|
2225
|
+
coverage: RightsizingScanCoverage
|
|
2226
|
+
workloads: RightsizingScanWorkload[]
|
|
2227
|
+
warnings?: { code: string; message: string }[]
|
|
2228
|
+
reason?: string
|
|
2229
|
+
}
|
|
1182
2230
|
|
|
1183
2231
|
// Check Prometheus availability
|
|
1184
2232
|
export function usePrometheusStatus() {
|
|
@@ -1190,12 +2238,32 @@ export function usePrometheusStatus() {
|
|
|
1190
2238
|
})
|
|
1191
2239
|
}
|
|
1192
2240
|
|
|
2241
|
+
export interface ArgoStatus {
|
|
2242
|
+
// configured = a URL or token is set; connected = a probe has landed and the
|
|
2243
|
+
// client is live. The two differ right after a restart (configured, reconnecting).
|
|
2244
|
+
configured: boolean
|
|
2245
|
+
connected: boolean
|
|
2246
|
+
address?: string
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
export function useArgoStatus(enabled = true) {
|
|
2250
|
+
return useQuery<ArgoStatus>({
|
|
2251
|
+
queryKey: ['argocd-status'],
|
|
2252
|
+
queryFn: () => fetchJSON('/integrations/argocd/status'),
|
|
2253
|
+
enabled,
|
|
2254
|
+
staleTime: 30000,
|
|
2255
|
+
refetchInterval: 60000,
|
|
2256
|
+
})
|
|
2257
|
+
}
|
|
2258
|
+
|
|
1193
2259
|
// Connect to Prometheus (trigger discovery)
|
|
1194
2260
|
export function usePrometheusConnect() {
|
|
1195
2261
|
const queryClient = useQueryClient()
|
|
1196
2262
|
return useMutation({
|
|
1197
2263
|
mutationFn: async () => {
|
|
1198
|
-
const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, {
|
|
2264
|
+
const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, {
|
|
2265
|
+
method: 'POST',
|
|
2266
|
+
})
|
|
1199
2267
|
if (!resp.ok) {
|
|
1200
2268
|
const body = await resp.json().catch(() => ({ error: 'Unknown error' }))
|
|
1201
2269
|
throw new Error(body.error || `HTTP ${resp.status}`)
|
|
@@ -1212,6 +2280,97 @@ export function usePrometheusConnect() {
|
|
|
1212
2280
|
})
|
|
1213
2281
|
}
|
|
1214
2282
|
|
|
2283
|
+
// Auto-discover Prometheus on first mount of any Prom-backed view, and
|
|
2284
|
+
// auto-reconnect across radar restarts on subsequent mounts.
|
|
2285
|
+
//
|
|
2286
|
+
// Two paths through this hook, both running once per cluster context per
|
|
2287
|
+
// component-instance:
|
|
2288
|
+
// 1. Cached path — localStorage flag means "Prom was discovered before on
|
|
2289
|
+
// this context". Probe fires immediately on mount; the user sees
|
|
2290
|
+
// charts populate without manual interaction.
|
|
2291
|
+
// 2. First-time path — no flag yet. Probe fires after a small delay so the
|
|
2292
|
+
// initial workload-view render lands before we hit the cluster network.
|
|
2293
|
+
// Behavior matches Lens / Headlamp defaults; the trade-off is one
|
|
2294
|
+
// cluster probe per session per fresh kubeconfig context.
|
|
2295
|
+
//
|
|
2296
|
+
// On success either way we set the flag, so subsequent mounts take path 1.
|
|
2297
|
+
// On failure we clear the flag (path 1) or leave it cleared (path 2) and
|
|
2298
|
+
// reset attemptedRef, so the existing "Discover Prometheus" CTA renders
|
|
2299
|
+
// once status refreshes. Manual interaction stays available as the fallback.
|
|
2300
|
+
//
|
|
2301
|
+
// localStorage is the right surface: connection intent is browser-local,
|
|
2302
|
+
// not a server-side preference, and we want it to persist across radar
|
|
2303
|
+
// restarts on the same port.
|
|
2304
|
+
const PROM_AUTOCONNECT_PREFIX = 'radar.prometheus.autoConnect:'
|
|
2305
|
+
// First-mount delay before probing the cluster. Chosen short enough that the
|
|
2306
|
+
// CTA → charts transition feels prompt, long enough that the probe doesn't
|
|
2307
|
+
// race the initial workload-view render.
|
|
2308
|
+
const PROM_FIRSTLAUNCH_PROBE_DELAY_MS = 500
|
|
2309
|
+
|
|
2310
|
+
function promAutoConnectKey(contextName: string): string {
|
|
2311
|
+
return `${PROM_AUTOCONNECT_PREFIX}${contextName}`
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
export function useAutoPromConnect(): void {
|
|
2315
|
+
const queryClient = useQueryClient()
|
|
2316
|
+
const { data: clusterInfo } = useClusterInfo()
|
|
2317
|
+
const { data: status, isLoading: statusLoading } = usePrometheusStatus()
|
|
2318
|
+
const attemptedRef = useRef<string | null>(null)
|
|
2319
|
+
|
|
2320
|
+
useEffect(() => {
|
|
2321
|
+
if (typeof window === 'undefined') return
|
|
2322
|
+
const context = clusterInfo?.context
|
|
2323
|
+
if (!context || statusLoading) return
|
|
2324
|
+
|
|
2325
|
+
// Persist the "we've connected here before" signal once a connection lands.
|
|
2326
|
+
if (status?.connected) {
|
|
2327
|
+
try {
|
|
2328
|
+
window.localStorage.setItem(promAutoConnectKey(context), '1')
|
|
2329
|
+
} catch {
|
|
2330
|
+
// localStorage can throw in some restricted browser modes — fail open.
|
|
2331
|
+
}
|
|
2332
|
+
return
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
if (attemptedRef.current === context) return
|
|
2336
|
+
let cached: string | null = null
|
|
2337
|
+
try {
|
|
2338
|
+
cached = window.localStorage.getItem(promAutoConnectKey(context))
|
|
2339
|
+
} catch {
|
|
2340
|
+
// keep the null fallback
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
attemptedRef.current = context
|
|
2344
|
+
|
|
2345
|
+
// Cached path probes immediately; first-time path defers briefly so the
|
|
2346
|
+
// initial UI render isn't competing with the cluster network call.
|
|
2347
|
+
const delay = cached === '1' ? 0 : PROM_FIRSTLAUNCH_PROBE_DELAY_MS
|
|
2348
|
+
const timeout = window.setTimeout(() => {
|
|
2349
|
+
// Direct apiFetch (not via the usePrometheusConnect mutation) so the
|
|
2350
|
+
// meta-driven toast handler stays silent — the user didn't click anything.
|
|
2351
|
+
apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, {
|
|
2352
|
+
method: 'POST',
|
|
2353
|
+
})
|
|
2354
|
+
.then(async (resp) => {
|
|
2355
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
2356
|
+
const nextStatus = (await resp.json()) as PrometheusStatus
|
|
2357
|
+
queryClient.setQueryData(['prometheus-status'], nextStatus)
|
|
2358
|
+
if (!nextStatus.connected) throw new Error(nextStatus.error || 'Prometheus unavailable')
|
|
2359
|
+
queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
|
|
2360
|
+
})
|
|
2361
|
+
.catch(() => {
|
|
2362
|
+
try {
|
|
2363
|
+
window.localStorage.removeItem(promAutoConnectKey(context))
|
|
2364
|
+
} catch {
|
|
2365
|
+
// ignore — manual CTA will render once status refreshes
|
|
2366
|
+
}
|
|
2367
|
+
attemptedRef.current = null
|
|
2368
|
+
})
|
|
2369
|
+
}, delay)
|
|
2370
|
+
return () => window.clearTimeout(timeout)
|
|
2371
|
+
}, [clusterInfo?.context, status?.connected, statusLoading, queryClient])
|
|
2372
|
+
}
|
|
2373
|
+
|
|
1215
2374
|
// Fetch Prometheus metrics for a resource
|
|
1216
2375
|
export function usePrometheusResourceMetrics(
|
|
1217
2376
|
kind: string,
|
|
@@ -1260,14 +2419,100 @@ export function usePrometheusClusterMetrics(
|
|
|
1260
2419
|
) {
|
|
1261
2420
|
return useQuery<PrometheusResourceMetrics>({
|
|
1262
2421
|
queryKey: ['prometheus-cluster-metrics', category, range],
|
|
1263
|
-
queryFn: () =>
|
|
1264
|
-
fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
|
|
2422
|
+
queryFn: () => fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
|
|
1265
2423
|
enabled,
|
|
1266
2424
|
staleTime: 30000,
|
|
1267
2425
|
refetchInterval: 60000,
|
|
1268
2426
|
})
|
|
1269
2427
|
}
|
|
1270
2428
|
|
|
2429
|
+
// Fetch PVC usage. hasData=false when no series — UI should hide the gauge.
|
|
2430
|
+
export function usePrometheusPVCUsage(namespace: string, name: string, enabled = true) {
|
|
2431
|
+
return useQuery<PrometheusPVCUsage>({
|
|
2432
|
+
queryKey: ['prometheus-pvc-usage', namespace, name],
|
|
2433
|
+
queryFn: () => fetchJSON(`/prometheus/pvc/${namespace}/${name}`),
|
|
2434
|
+
enabled: enabled && Boolean(namespace && name),
|
|
2435
|
+
staleTime: 60000,
|
|
2436
|
+
refetchInterval: 120000,
|
|
2437
|
+
})
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// Fetch rightsizing recommendations for a workload (Deployment / StatefulSet / DaemonSet).
|
|
2441
|
+
export function usePrometheusRightsizing(
|
|
2442
|
+
kind: string,
|
|
2443
|
+
namespace: string,
|
|
2444
|
+
name: string,
|
|
2445
|
+
enabled = true,
|
|
2446
|
+
) {
|
|
2447
|
+
return useQuery<PrometheusRightsizing>({
|
|
2448
|
+
queryKey: ['prometheus-rightsizing', kind, namespace, name],
|
|
2449
|
+
queryFn: () => fetchJSON(`/prometheus/rightsizing/${kind}/${namespace}/${name}`),
|
|
2450
|
+
enabled: enabled && Boolean(kind && namespace && name),
|
|
2451
|
+
staleTime: 5 * 60 * 1000,
|
|
2452
|
+
refetchInterval: 10 * 60 * 1000,
|
|
2453
|
+
})
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
const RIGHTSIZING_SCAN_CACHE_TIME = 5 * 60 * 1000
|
|
2457
|
+
|
|
2458
|
+
export function getRightsizingScanCacheConfig(
|
|
2459
|
+
namespaces: string[],
|
|
2460
|
+
context = '',
|
|
2461
|
+
): {
|
|
2462
|
+
namespaceKey: string
|
|
2463
|
+
queryKey: readonly ['prometheus-rightsizing-scan', string, string]
|
|
2464
|
+
queryFn: typeof skipToken
|
|
2465
|
+
gcTime: number
|
|
2466
|
+
} {
|
|
2467
|
+
const namespaceKey = [...namespaces].sort().join(',')
|
|
2468
|
+
return {
|
|
2469
|
+
namespaceKey,
|
|
2470
|
+
queryKey: ['prometheus-rightsizing-scan', context, namespaceKey] as const,
|
|
2471
|
+
queryFn: skipToken,
|
|
2472
|
+
gcTime: RIGHTSIZING_SCAN_CACHE_TIME,
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
// A fleet rightsizing scan is intentionally manual. It can query seven days of
|
|
2477
|
+
// Prometheus history for many containers, so navigation alone must never run it.
|
|
2478
|
+
export function useRightsizingScan(namespaces: string[], context = '') {
|
|
2479
|
+
const queryClient = useQueryClient()
|
|
2480
|
+
const { namespaceKey, ...snapshotOptions } = getRightsizingScanCacheConfig(namespaces, context)
|
|
2481
|
+
const scanScope = { namespaceKey, queryKey: snapshotOptions.queryKey }
|
|
2482
|
+
const snapshot = useQuery<RightsizingScanResponse>(snapshotOptions)
|
|
2483
|
+
const mutation = useMutation({
|
|
2484
|
+
mutationFn: async (startedScope: typeof scanScope) => {
|
|
2485
|
+
const params = new URLSearchParams()
|
|
2486
|
+
if (startedScope.namespaceKey) params.set('namespaces', startedScope.namespaceKey)
|
|
2487
|
+
const query = params.toString()
|
|
2488
|
+
return fetchJSON<RightsizingScanResponse>(
|
|
2489
|
+
`/prometheus/rightsizing/scan${query ? `?${query}` : ''}`,
|
|
2490
|
+
{
|
|
2491
|
+
method: 'POST',
|
|
2492
|
+
},
|
|
2493
|
+
)
|
|
2494
|
+
},
|
|
2495
|
+
onSuccess: (result, startedScope) => queryClient.setQueryData(startedScope.queryKey, result),
|
|
2496
|
+
})
|
|
2497
|
+
return {
|
|
2498
|
+
...mutation,
|
|
2499
|
+
data: snapshot.data,
|
|
2500
|
+
mutate: () => mutation.mutate(scanScope),
|
|
2501
|
+
mutateAsync: () => mutation.mutateAsync(scanScope),
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
|
|
2506
|
+
export function usePromQLRange(query: string, range: PrometheusTimeRange = '1h', enabled = true) {
|
|
2507
|
+
return useQuery<PrometheusQueryResult>({
|
|
2508
|
+
queryKey: ['promql-range', query, range],
|
|
2509
|
+
queryFn: () => fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
|
|
2510
|
+
enabled: enabled && Boolean(query),
|
|
2511
|
+
staleTime: 30000,
|
|
2512
|
+
refetchInterval: 60000,
|
|
2513
|
+
})
|
|
2514
|
+
}
|
|
2515
|
+
|
|
1271
2516
|
// ============================================================================
|
|
1272
2517
|
// Pod Logs
|
|
1273
2518
|
// ============================================================================
|
|
@@ -1294,12 +2539,16 @@ export interface LogStreamEvent {
|
|
|
1294
2539
|
}
|
|
1295
2540
|
|
|
1296
2541
|
// Fetch pod logs (non-streaming)
|
|
1297
|
-
export function usePodLogs(
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
2542
|
+
export function usePodLogs(
|
|
2543
|
+
namespace: string,
|
|
2544
|
+
podName: string,
|
|
2545
|
+
options?: {
|
|
2546
|
+
container?: string
|
|
2547
|
+
tailLines?: number
|
|
2548
|
+
previous?: boolean
|
|
2549
|
+
sinceSeconds?: number
|
|
2550
|
+
},
|
|
2551
|
+
) {
|
|
1303
2552
|
const params = new URLSearchParams()
|
|
1304
2553
|
if (options?.container) params.set('container', options.container)
|
|
1305
2554
|
if (options?.tailLines) params.set('tailLines', String(options.tailLines))
|
|
@@ -1308,8 +2557,17 @@ export function usePodLogs(namespace: string, podName: string, options?: {
|
|
|
1308
2557
|
const queryString = params.toString()
|
|
1309
2558
|
|
|
1310
2559
|
return useQuery<LogsResponse>({
|
|
1311
|
-
queryKey: [
|
|
1312
|
-
|
|
2560
|
+
queryKey: [
|
|
2561
|
+
'pod-logs',
|
|
2562
|
+
namespace,
|
|
2563
|
+
podName,
|
|
2564
|
+
options?.container,
|
|
2565
|
+
options?.tailLines,
|
|
2566
|
+
options?.previous,
|
|
2567
|
+
options?.sinceSeconds,
|
|
2568
|
+
],
|
|
2569
|
+
queryFn: () =>
|
|
2570
|
+
fetchJSON(`/pods/${namespace}/${podName}/logs${queryString ? `?${queryString}` : ''}`),
|
|
1313
2571
|
enabled: Boolean(namespace && podName),
|
|
1314
2572
|
staleTime: 5000, // Allow refetch after 5 seconds
|
|
1315
2573
|
})
|
|
@@ -1324,7 +2582,7 @@ export function createLogStream(
|
|
|
1324
2582
|
tailLines?: number
|
|
1325
2583
|
previous?: boolean
|
|
1326
2584
|
sinceSeconds?: number
|
|
1327
|
-
}
|
|
2585
|
+
},
|
|
1328
2586
|
): EventSource {
|
|
1329
2587
|
const params = new URLSearchParams()
|
|
1330
2588
|
if (options?.container) params.set('container', options.container)
|
|
@@ -1333,9 +2591,12 @@ export function createLogStream(
|
|
|
1333
2591
|
if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
|
|
1334
2592
|
const queryString = params.toString()
|
|
1335
2593
|
|
|
1336
|
-
return new EventSource(
|
|
1337
|
-
|
|
1338
|
-
|
|
2594
|
+
return new EventSource(
|
|
2595
|
+
`${getApiBase()}/pods/${namespace}/${podName}/logs/stream${queryString ? `?${queryString}` : ''}`,
|
|
2596
|
+
{
|
|
2597
|
+
withCredentials: getCredentialsMode() === 'include',
|
|
2598
|
+
},
|
|
2599
|
+
)
|
|
1339
2600
|
}
|
|
1340
2601
|
|
|
1341
2602
|
// ============================================================================
|
|
@@ -1347,6 +2608,7 @@ export interface AvailablePort {
|
|
|
1347
2608
|
protocol: string
|
|
1348
2609
|
containerName?: string
|
|
1349
2610
|
name?: string
|
|
2611
|
+
scheme?: 'http' | 'https'
|
|
1350
2612
|
}
|
|
1351
2613
|
|
|
1352
2614
|
export function useAvailablePorts(type: 'pod' | 'service', namespace: string, name: string) {
|
|
@@ -1367,8 +2629,37 @@ export function useUpdateResource() {
|
|
|
1367
2629
|
const queryClient = useQueryClient()
|
|
1368
2630
|
|
|
1369
2631
|
return useMutation({
|
|
1370
|
-
mutationFn: async ({
|
|
1371
|
-
|
|
2632
|
+
mutationFn: async ({
|
|
2633
|
+
kind,
|
|
2634
|
+
namespace,
|
|
2635
|
+
name,
|
|
2636
|
+
yaml,
|
|
2637
|
+
force = true,
|
|
2638
|
+
reviewedResourceVersion,
|
|
2639
|
+
reviewedContext,
|
|
2640
|
+
}: {
|
|
2641
|
+
kind: string
|
|
2642
|
+
namespace: string
|
|
2643
|
+
name: string
|
|
2644
|
+
yaml: string
|
|
2645
|
+
force?: boolean
|
|
2646
|
+
reviewedResourceVersion?: string
|
|
2647
|
+
reviewedContext?: string
|
|
2648
|
+
}) => {
|
|
2649
|
+
const url = new URL(
|
|
2650
|
+
`${getApiBase()}/resources/${kind}/${namespace}/${name}`,
|
|
2651
|
+
window.location.origin,
|
|
2652
|
+
)
|
|
2653
|
+
if (!force) {
|
|
2654
|
+
url.searchParams.set('force', 'false')
|
|
2655
|
+
}
|
|
2656
|
+
if (reviewedResourceVersion) {
|
|
2657
|
+
url.searchParams.set('resourceVersion', reviewedResourceVersion)
|
|
2658
|
+
}
|
|
2659
|
+
if (reviewedContext) {
|
|
2660
|
+
url.searchParams.set('reviewedContext', reviewedContext)
|
|
2661
|
+
}
|
|
2662
|
+
const response = await apiFetch(url.toString(), {
|
|
1372
2663
|
method: 'PUT',
|
|
1373
2664
|
headers: { 'Content-Type': 'text/plain' },
|
|
1374
2665
|
body: yaml,
|
|
@@ -1383,9 +2674,31 @@ export function useUpdateResource() {
|
|
|
1383
2674
|
errorMessage: 'Failed to update resource',
|
|
1384
2675
|
successMessage: 'Resource updated',
|
|
1385
2676
|
},
|
|
1386
|
-
onSuccess: (
|
|
1387
|
-
|
|
1388
|
-
|
|
2677
|
+
onSuccess: (updated: any, variables) => {
|
|
2678
|
+
// The PUT goes straight to the apiserver and returns the authoritative
|
|
2679
|
+
// object, but the GET behind this query reads Radar's informer cache,
|
|
2680
|
+
// which lags a write by one watch round-trip. Seed the detail cache with
|
|
2681
|
+
// the PUT response so the edit shows immediately. Invalidating here
|
|
2682
|
+
// instead would trigger a refetch that races the seed and re-reads the
|
|
2683
|
+
// lagging cache — the change appears not to have taken effect.
|
|
2684
|
+
if (updated && typeof updated === 'object' && updated.metadata) {
|
|
2685
|
+
queryClient.setQueriesData(
|
|
2686
|
+
{
|
|
2687
|
+
queryKey: ['resource', variables.kind, variables.namespace, variables.name],
|
|
2688
|
+
},
|
|
2689
|
+
(old: any) =>
|
|
2690
|
+
old && typeof old === 'object' && 'resource' in old
|
|
2691
|
+
? { ...old, resource: updated }
|
|
2692
|
+
: { resource: updated },
|
|
2693
|
+
)
|
|
2694
|
+
} else {
|
|
2695
|
+
queryClient.invalidateQueries({
|
|
2696
|
+
queryKey: ['resource', variables.kind, variables.namespace, variables.name],
|
|
2697
|
+
})
|
|
2698
|
+
}
|
|
2699
|
+
queryClient.invalidateQueries({
|
|
2700
|
+
queryKey: ['resources', variables.kind],
|
|
2701
|
+
})
|
|
1389
2702
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1390
2703
|
},
|
|
1391
2704
|
})
|
|
@@ -1394,13 +2707,24 @@ export function useUpdateResource() {
|
|
|
1394
2707
|
// Cascade delete preview — shows resources that will be garbage-collected
|
|
1395
2708
|
export interface CascadeDeletePreview {
|
|
1396
2709
|
root: { kind: string; namespace: string; name: string; group?: string }
|
|
1397
|
-
dependents: {
|
|
2710
|
+
dependents: {
|
|
2711
|
+
kind: string
|
|
2712
|
+
namespace: string
|
|
2713
|
+
name: string
|
|
2714
|
+
group?: string
|
|
2715
|
+
}[]
|
|
1398
2716
|
}
|
|
1399
2717
|
|
|
1400
|
-
export function useCascadeDeletePreview(
|
|
2718
|
+
export function useCascadeDeletePreview(
|
|
2719
|
+
kind: string,
|
|
2720
|
+
namespace: string,
|
|
2721
|
+
name: string,
|
|
2722
|
+
enabled: boolean,
|
|
2723
|
+
) {
|
|
1401
2724
|
return useQuery<CascadeDeletePreview>({
|
|
1402
2725
|
queryKey: ['cascade-preview', kind, namespace, name],
|
|
1403
|
-
queryFn: () =>
|
|
2726
|
+
queryFn: () =>
|
|
2727
|
+
fetchJSON<CascadeDeletePreview>(`/resources/${kind}/${namespace}/${name}/cascade-preview`),
|
|
1404
2728
|
enabled,
|
|
1405
2729
|
staleTime: 30_000,
|
|
1406
2730
|
})
|
|
@@ -1411,8 +2735,26 @@ export function useDeleteResource() {
|
|
|
1411
2735
|
const queryClient = useQueryClient()
|
|
1412
2736
|
|
|
1413
2737
|
return useMutation({
|
|
1414
|
-
mutationFn: async ({
|
|
1415
|
-
|
|
2738
|
+
mutationFn: async ({
|
|
2739
|
+
kind,
|
|
2740
|
+
group,
|
|
2741
|
+
namespace,
|
|
2742
|
+
name,
|
|
2743
|
+
force,
|
|
2744
|
+
}: {
|
|
2745
|
+
kind: string
|
|
2746
|
+
group?: string
|
|
2747
|
+
namespace: string
|
|
2748
|
+
name: string
|
|
2749
|
+
force?: boolean
|
|
2750
|
+
}) => {
|
|
2751
|
+
const url = new URL(
|
|
2752
|
+
`${getApiBase()}/resources/${kind}/${namespace}/${name}`,
|
|
2753
|
+
window.location.origin,
|
|
2754
|
+
)
|
|
2755
|
+
if (group) {
|
|
2756
|
+
url.searchParams.set('group', group)
|
|
2757
|
+
}
|
|
1416
2758
|
if (force) {
|
|
1417
2759
|
url.searchParams.set('force', 'true')
|
|
1418
2760
|
}
|
|
@@ -1431,7 +2773,217 @@ export function useDeleteResource() {
|
|
|
1431
2773
|
successMessage: 'Resource deleted',
|
|
1432
2774
|
},
|
|
1433
2775
|
onSuccess: (_, variables) => {
|
|
1434
|
-
queryClient.invalidateQueries({
|
|
2776
|
+
queryClient.invalidateQueries({
|
|
2777
|
+
queryKey: ['resources', variables.kind],
|
|
2778
|
+
})
|
|
2779
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
2780
|
+
},
|
|
2781
|
+
})
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
export function useBulkDeleteResources() {
|
|
2785
|
+
const queryClient = useQueryClient()
|
|
2786
|
+
|
|
2787
|
+
return useMutation({
|
|
2788
|
+
mutationFn: async ({
|
|
2789
|
+
items,
|
|
2790
|
+
force,
|
|
2791
|
+
}: {
|
|
2792
|
+
items: Array<{
|
|
2793
|
+
kind: string
|
|
2794
|
+
group?: string
|
|
2795
|
+
namespace: string
|
|
2796
|
+
name: string
|
|
2797
|
+
}>
|
|
2798
|
+
force?: boolean
|
|
2799
|
+
}) => {
|
|
2800
|
+
const results = await Promise.allSettled(
|
|
2801
|
+
items.map(async ({ kind, group, namespace, name }) => {
|
|
2802
|
+
const url = new URL(
|
|
2803
|
+
`${getApiBase()}/resources/${kind}/${namespace}/${name}`,
|
|
2804
|
+
window.location.origin,
|
|
2805
|
+
)
|
|
2806
|
+
if (group) url.searchParams.set('group', group)
|
|
2807
|
+
if (force) url.searchParams.set('force', 'true')
|
|
2808
|
+
const response = await apiFetch(url.toString(), { method: 'DELETE' })
|
|
2809
|
+
if (!response.ok) {
|
|
2810
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2811
|
+
throw new Error(error.error || `Failed to delete ${namespace}/${name}`)
|
|
2812
|
+
}
|
|
2813
|
+
return { kind, namespace, name }
|
|
2814
|
+
}),
|
|
2815
|
+
)
|
|
2816
|
+
const failed = results.filter((r) => r.status === 'rejected')
|
|
2817
|
+
if (failed.length > 0) {
|
|
2818
|
+
throw new Error(`Failed to delete ${failed.length} of ${items.length} resources`)
|
|
2819
|
+
}
|
|
2820
|
+
return { deleted: items.length }
|
|
2821
|
+
},
|
|
2822
|
+
meta: {
|
|
2823
|
+
errorMessage: 'Failed to delete some resources',
|
|
2824
|
+
successMessage: 'Resources deleted',
|
|
2825
|
+
},
|
|
2826
|
+
// onSettled, not onSuccess — a partial failure still deleted some
|
|
2827
|
+
// resources, and the table must refetch to drop them.
|
|
2828
|
+
onSettled: () => {
|
|
2829
|
+
queryClient.invalidateQueries({ queryKey: ['resources'] })
|
|
2830
|
+
queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
|
|
2831
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
2832
|
+
},
|
|
2833
|
+
})
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
interface BulkWorkloadItem {
|
|
2837
|
+
kind: string
|
|
2838
|
+
namespace: string
|
|
2839
|
+
name: string
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
interface BulkWorkloadMutationResult {
|
|
2843
|
+
requested: number
|
|
2844
|
+
succeeded: number
|
|
2845
|
+
failedMessages: string[]
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
function failedBulkWorkloadMessages(results: PromiseSettledResult<unknown>[]): string[] {
|
|
2849
|
+
return results.flatMap((r) =>
|
|
2850
|
+
r.status === 'rejected'
|
|
2851
|
+
? [r.reason instanceof Error ? r.reason.message : String(r.reason)]
|
|
2852
|
+
: [],
|
|
2853
|
+
)
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
function bulkWorkloadFailureMessage(
|
|
2857
|
+
action: string,
|
|
2858
|
+
failed: number,
|
|
2859
|
+
total: number,
|
|
2860
|
+
messages: string[],
|
|
2861
|
+
): string {
|
|
2862
|
+
return `Failed to ${action} ${failed} of ${total} workloads:\n${messages.join('\n')}`
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
export function useBulkRestartWorkloads() {
|
|
2866
|
+
const queryClient = useQueryClient()
|
|
2867
|
+
|
|
2868
|
+
return useMutation({
|
|
2869
|
+
mutationFn: async ({
|
|
2870
|
+
items,
|
|
2871
|
+
}: {
|
|
2872
|
+
items: BulkWorkloadItem[]
|
|
2873
|
+
}): Promise<BulkWorkloadMutationResult> => {
|
|
2874
|
+
if (items.length === 0) {
|
|
2875
|
+
return { requested: 0, succeeded: 0, failedMessages: [] }
|
|
2876
|
+
}
|
|
2877
|
+
const results = await Promise.allSettled(
|
|
2878
|
+
items.map(async ({ kind, namespace, name }) => {
|
|
2879
|
+
const response = await apiFetch(
|
|
2880
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
|
|
2881
|
+
{
|
|
2882
|
+
method: 'POST',
|
|
2883
|
+
},
|
|
2884
|
+
)
|
|
2885
|
+
if (!response.ok) {
|
|
2886
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2887
|
+
throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
|
|
2888
|
+
}
|
|
2889
|
+
return { kind, namespace, name }
|
|
2890
|
+
}),
|
|
2891
|
+
)
|
|
2892
|
+
const failedMessages = failedBulkWorkloadMessages(results)
|
|
2893
|
+
if (failedMessages.length === items.length) {
|
|
2894
|
+
throw new Error(
|
|
2895
|
+
bulkWorkloadFailureMessage(
|
|
2896
|
+
'restart',
|
|
2897
|
+
failedMessages.length,
|
|
2898
|
+
items.length,
|
|
2899
|
+
failedMessages,
|
|
2900
|
+
),
|
|
2901
|
+
)
|
|
2902
|
+
}
|
|
2903
|
+
return {
|
|
2904
|
+
requested: items.length,
|
|
2905
|
+
succeeded: items.length - failedMessages.length,
|
|
2906
|
+
failedMessages,
|
|
2907
|
+
}
|
|
2908
|
+
},
|
|
2909
|
+
meta: {
|
|
2910
|
+
errorMessage: 'Failed to restart some workloads',
|
|
2911
|
+
},
|
|
2912
|
+
onSuccess: (result) => {
|
|
2913
|
+
if (result.failedMessages.length > 0) {
|
|
2914
|
+
showApiError(
|
|
2915
|
+
`Restarted ${result.succeeded} of ${result.requested} workloads`,
|
|
2916
|
+
result.failedMessages.join('\n'),
|
|
2917
|
+
)
|
|
2918
|
+
} else {
|
|
2919
|
+
showApiSuccess('Workloads restarting')
|
|
2920
|
+
}
|
|
2921
|
+
},
|
|
2922
|
+
onSettled: () => {
|
|
2923
|
+
queryClient.invalidateQueries({ queryKey: ['resources'] })
|
|
2924
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
2925
|
+
},
|
|
2926
|
+
})
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
export function useBulkScaleWorkloads() {
|
|
2930
|
+
const queryClient = useQueryClient()
|
|
2931
|
+
|
|
2932
|
+
return useMutation({
|
|
2933
|
+
mutationFn: async ({
|
|
2934
|
+
items,
|
|
2935
|
+
replicas,
|
|
2936
|
+
}: {
|
|
2937
|
+
items: BulkWorkloadItem[]
|
|
2938
|
+
replicas: number
|
|
2939
|
+
}): Promise<BulkWorkloadMutationResult> => {
|
|
2940
|
+
if (items.length === 0) {
|
|
2941
|
+
return { requested: 0, succeeded: 0, failedMessages: [] }
|
|
2942
|
+
}
|
|
2943
|
+
const results = await Promise.allSettled(
|
|
2944
|
+
items.map(async ({ kind, namespace, name }) => {
|
|
2945
|
+
const response = await apiFetch(
|
|
2946
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
|
|
2947
|
+
{
|
|
2948
|
+
method: 'POST',
|
|
2949
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2950
|
+
body: JSON.stringify({ replicas }),
|
|
2951
|
+
},
|
|
2952
|
+
)
|
|
2953
|
+
if (!response.ok) {
|
|
2954
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2955
|
+
throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
|
|
2956
|
+
}
|
|
2957
|
+
return { kind, namespace, name }
|
|
2958
|
+
}),
|
|
2959
|
+
)
|
|
2960
|
+
const failedMessages = failedBulkWorkloadMessages(results)
|
|
2961
|
+
if (failedMessages.length === items.length) {
|
|
2962
|
+
throw new Error(
|
|
2963
|
+
bulkWorkloadFailureMessage('scale', failedMessages.length, items.length, failedMessages),
|
|
2964
|
+
)
|
|
2965
|
+
}
|
|
2966
|
+
return {
|
|
2967
|
+
requested: items.length,
|
|
2968
|
+
succeeded: items.length - failedMessages.length,
|
|
2969
|
+
failedMessages,
|
|
2970
|
+
}
|
|
2971
|
+
},
|
|
2972
|
+
meta: {
|
|
2973
|
+
errorMessage: 'Failed to scale some workloads',
|
|
2974
|
+
},
|
|
2975
|
+
onSuccess: (result) => {
|
|
2976
|
+
if (result.failedMessages.length > 0) {
|
|
2977
|
+
showApiError(
|
|
2978
|
+
`Scaled ${result.succeeded} of ${result.requested} workloads`,
|
|
2979
|
+
result.failedMessages.join('\n'),
|
|
2980
|
+
)
|
|
2981
|
+
} else {
|
|
2982
|
+
showApiSuccess('Workloads scaled')
|
|
2983
|
+
}
|
|
2984
|
+
},
|
|
2985
|
+
onSettled: () => {
|
|
2986
|
+
queryClient.invalidateQueries({ queryKey: ['resources'] })
|
|
1435
2987
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1436
2988
|
},
|
|
1437
2989
|
})
|
|
@@ -1445,30 +2997,193 @@ export interface ApplyResourceResult {
|
|
|
1445
2997
|
created: boolean
|
|
1446
2998
|
}
|
|
1447
2999
|
|
|
3000
|
+
interface ApplyResourceErrorResponse {
|
|
3001
|
+
error?: string
|
|
3002
|
+
results?: ApplyResourceResult[]
|
|
3003
|
+
failedIndex?: number
|
|
3004
|
+
total?: number
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
export class ApplyResourceError extends Error {
|
|
3008
|
+
readonly appliedResults: ApplyResourceResult[]
|
|
3009
|
+
readonly failedIndex?: number
|
|
3010
|
+
readonly total?: number
|
|
3011
|
+
|
|
3012
|
+
constructor(payload: ApplyResourceErrorResponse, status: number) {
|
|
3013
|
+
super(formatApplyResourceError(payload, status))
|
|
3014
|
+
this.name = 'ApplyResourceError'
|
|
3015
|
+
this.appliedResults = payload.results ?? []
|
|
3016
|
+
this.failedIndex = payload.failedIndex
|
|
3017
|
+
this.total = payload.total
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
export function formatApplyResourceError(
|
|
3022
|
+
payload: ApplyResourceErrorResponse,
|
|
3023
|
+
status: number,
|
|
3024
|
+
): string {
|
|
3025
|
+
const message = payload.error || `HTTP ${status}`
|
|
3026
|
+
const applied = payload.results ?? []
|
|
3027
|
+
if (applied.length === 0 || payload.failedIndex === undefined) return message
|
|
3028
|
+
|
|
3029
|
+
const total = payload.total ?? applied.length + 1
|
|
3030
|
+
const appliedLabel = applied.length === 1 ? 'resource was' : 'resources were'
|
|
3031
|
+
const names = applied
|
|
3032
|
+
.slice(0, 3)
|
|
3033
|
+
.map(({ kind, namespace, name }) => `${kind} ${namespace ? `${namespace}/` : ''}${name}`)
|
|
3034
|
+
.join(', ')
|
|
3035
|
+
const more = applied.length > 3 ? ` and ${applied.length - 3} more` : ''
|
|
3036
|
+
const cause = message.replace(/^document \d+:\s*/i, '')
|
|
3037
|
+
return `${applied.length} of ${total} ${appliedLabel} applied before document ${payload.failedIndex + 1} failed. Applied: ${names}${more}. ${cause}`
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
interface YamlSchemaResponse {
|
|
3041
|
+
documents: Array<{
|
|
3042
|
+
index: number
|
|
3043
|
+
status: 'available' | 'unavailable'
|
|
3044
|
+
bundleKey?: string
|
|
3045
|
+
schemaRef?: string
|
|
3046
|
+
error?: string
|
|
3047
|
+
}>
|
|
3048
|
+
bundles: Record<string, { definitions: Record<string, unknown> }>
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
export async function fetchYamlSchemas(
|
|
3052
|
+
documents: YamlDocumentIdentity[],
|
|
3053
|
+
): Promise<YamlSchemaLoadResult> {
|
|
3054
|
+
const response = await apiFetch(`${getApiBase()}/resources/schemas`, {
|
|
3055
|
+
method: 'POST',
|
|
3056
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3057
|
+
body: JSON.stringify({
|
|
3058
|
+
documents: documents.map(({ index, apiVersion, kind }) => ({
|
|
3059
|
+
index,
|
|
3060
|
+
apiVersion,
|
|
3061
|
+
kind,
|
|
3062
|
+
})),
|
|
3063
|
+
}),
|
|
3064
|
+
})
|
|
3065
|
+
if (!response.ok) {
|
|
3066
|
+
const error = await response.json().catch(() => ({ error: 'Cluster schemas are unavailable' }))
|
|
3067
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
3068
|
+
}
|
|
3069
|
+
const result = (await response.json()) as YamlSchemaResponse
|
|
3070
|
+
const schemas: Array<Record<string, unknown> | null> = documents.map(() => null)
|
|
3071
|
+
const unavailable: Array<{ index: number; reason: string }> = []
|
|
3072
|
+
for (const document of result.documents) {
|
|
3073
|
+
const position = documents.findIndex(({ index }) => index === document.index)
|
|
3074
|
+
if (position < 0) continue
|
|
3075
|
+
const bundle = document.bundleKey ? result.bundles[document.bundleKey] : undefined
|
|
3076
|
+
if (document.status === 'available' && document.schemaRef && bundle) {
|
|
3077
|
+
schemas[position] = {
|
|
3078
|
+
$ref: document.schemaRef,
|
|
3079
|
+
definitions: bundle.definitions,
|
|
3080
|
+
}
|
|
3081
|
+
} else {
|
|
3082
|
+
unavailable.push({
|
|
3083
|
+
index: document.index,
|
|
3084
|
+
reason: document.error || 'Schema unavailable',
|
|
3085
|
+
})
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
return { schemas, unavailable }
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
export interface YamlPreviewDocument {
|
|
3092
|
+
index: number
|
|
3093
|
+
status: 'accepted' | 'rejected' | 'unavailable'
|
|
3094
|
+
apiVersion?: string
|
|
3095
|
+
kind?: string
|
|
3096
|
+
namespace?: string
|
|
3097
|
+
name?: string
|
|
3098
|
+
action?: 'create' | 'update' | 'unknown'
|
|
3099
|
+
submittedYaml?: string
|
|
3100
|
+
baselineYaml?: string
|
|
3101
|
+
predictedYaml?: string
|
|
3102
|
+
warnings?: string[]
|
|
3103
|
+
error?: string
|
|
3104
|
+
reviewedResourceVersion?: string
|
|
3105
|
+
redacted?: boolean
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
export interface YamlPreviewResponse {
|
|
3109
|
+
documents: YamlPreviewDocument[]
|
|
3110
|
+
nonAtomic: boolean
|
|
3111
|
+
context: string
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
export interface YamlPreviewRequest {
|
|
3115
|
+
yaml: string
|
|
3116
|
+
mode: 'apply' | 'create' | 'update'
|
|
3117
|
+
force: boolean
|
|
3118
|
+
target?: { kind: string; namespace: string; name: string }
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
export function usePreviewResources() {
|
|
3122
|
+
return useMutation({
|
|
3123
|
+
mutationFn: async (request: YamlPreviewRequest) => {
|
|
3124
|
+
const response = await apiFetch(`${getApiBase()}/resources/preview`, {
|
|
3125
|
+
method: 'POST',
|
|
3126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3127
|
+
body: JSON.stringify(request),
|
|
3128
|
+
})
|
|
3129
|
+
if (!response.ok) {
|
|
3130
|
+
const error = await response.json().catch(() => ({ error: 'Preview failed' }))
|
|
3131
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
3132
|
+
}
|
|
3133
|
+
return response.json() as Promise<YamlPreviewResponse>
|
|
3134
|
+
},
|
|
3135
|
+
})
|
|
3136
|
+
}
|
|
3137
|
+
|
|
1448
3138
|
export function useApplyResource() {
|
|
1449
3139
|
const queryClient = useQueryClient()
|
|
1450
3140
|
|
|
1451
3141
|
return useMutation({
|
|
1452
|
-
mutationFn: async ({
|
|
3142
|
+
mutationFn: async ({
|
|
3143
|
+
yaml,
|
|
3144
|
+
mode = 'apply',
|
|
3145
|
+
dryRun = false,
|
|
3146
|
+
force = false,
|
|
3147
|
+
reviewedResourceVersions,
|
|
3148
|
+
reviewedContext,
|
|
3149
|
+
}: {
|
|
3150
|
+
yaml: string
|
|
3151
|
+
mode?: 'apply' | 'create'
|
|
3152
|
+
dryRun?: boolean
|
|
3153
|
+
force?: boolean
|
|
3154
|
+
reviewedResourceVersions?: Record<number, string>
|
|
3155
|
+
reviewedContext?: string
|
|
3156
|
+
}) => {
|
|
1453
3157
|
const url = new URL(`${getApiBase()}/resources/apply`, window.location.origin)
|
|
1454
3158
|
url.searchParams.set('mode', mode)
|
|
1455
3159
|
if (dryRun) {
|
|
1456
3160
|
url.searchParams.set('dryRun', 'true')
|
|
1457
3161
|
}
|
|
3162
|
+
if (force) {
|
|
3163
|
+
url.searchParams.set('force', 'true')
|
|
3164
|
+
}
|
|
3165
|
+
if (reviewedResourceVersions && Object.keys(reviewedResourceVersions).length > 0) {
|
|
3166
|
+
url.searchParams.set('reviewedVersions', JSON.stringify(reviewedResourceVersions))
|
|
3167
|
+
}
|
|
3168
|
+
if (reviewedContext) {
|
|
3169
|
+
url.searchParams.set('reviewedContext', reviewedContext)
|
|
3170
|
+
}
|
|
1458
3171
|
const response = await apiFetch(url.toString(), {
|
|
1459
3172
|
method: 'POST',
|
|
1460
3173
|
headers: { 'Content-Type': 'text/plain' },
|
|
1461
3174
|
body: yaml,
|
|
1462
3175
|
})
|
|
1463
3176
|
if (!response.ok) {
|
|
1464
|
-
const error = await response
|
|
1465
|
-
|
|
3177
|
+
const error = (await response
|
|
3178
|
+
.json()
|
|
3179
|
+
.catch(() => ({ error: 'Unknown error' }))) as ApplyResourceErrorResponse
|
|
3180
|
+
throw new ApplyResourceError(error, response.status)
|
|
1466
3181
|
}
|
|
1467
3182
|
return response.json() as Promise<ApplyResourceResult[]>
|
|
1468
3183
|
},
|
|
1469
3184
|
// No meta errorMessage/successMessage — the CreateResourceDialog
|
|
1470
3185
|
// handles all feedback inline to avoid duplicate toasts.
|
|
1471
|
-
|
|
3186
|
+
onSettled: () => {
|
|
1472
3187
|
queryClient.invalidateQueries({ queryKey: ['resources'] })
|
|
1473
3188
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1474
3189
|
},
|
|
@@ -1479,6 +3194,25 @@ export function useApplyResource() {
|
|
|
1479
3194
|
// CronJob operations
|
|
1480
3195
|
// ============================================================================
|
|
1481
3196
|
|
|
3197
|
+
function invalidateCronJobOperationQueries(
|
|
3198
|
+
queryClient: ReturnType<typeof useQueryClient>,
|
|
3199
|
+
namespace: string,
|
|
3200
|
+
name: string,
|
|
3201
|
+
) {
|
|
3202
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
|
|
3203
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
|
|
3204
|
+
queryClient.invalidateQueries({
|
|
3205
|
+
queryKey: ['resource', 'cronjobs', namespace, name],
|
|
3206
|
+
})
|
|
3207
|
+
queryClient.invalidateQueries({
|
|
3208
|
+
queryKey: ['workload-runs', 'cronjobs', namespace, name],
|
|
3209
|
+
})
|
|
3210
|
+
queryClient.invalidateQueries({ queryKey: ['applications'] })
|
|
3211
|
+
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
|
3212
|
+
queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
|
|
3213
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
3214
|
+
}
|
|
3215
|
+
|
|
1482
3216
|
// Trigger a CronJob (create a Job from it)
|
|
1483
3217
|
export function useTriggerCronJob() {
|
|
1484
3218
|
const queryClient = useQueryClient()
|
|
@@ -1498,10 +3232,8 @@ export function useTriggerCronJob() {
|
|
|
1498
3232
|
errorMessage: 'Failed to trigger CronJob',
|
|
1499
3233
|
successMessage: 'CronJob triggered',
|
|
1500
3234
|
},
|
|
1501
|
-
onSuccess: () => {
|
|
1502
|
-
queryClient.
|
|
1503
|
-
queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
|
|
1504
|
-
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
3235
|
+
onSuccess: (_, variables) => {
|
|
3236
|
+
invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
|
|
1505
3237
|
},
|
|
1506
3238
|
})
|
|
1507
3239
|
}
|
|
@@ -1525,9 +3257,8 @@ export function useSuspendCronJob() {
|
|
|
1525
3257
|
errorMessage: 'Failed to suspend CronJob',
|
|
1526
3258
|
successMessage: 'CronJob suspended',
|
|
1527
3259
|
},
|
|
1528
|
-
onSuccess: () => {
|
|
1529
|
-
queryClient.
|
|
1530
|
-
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
3260
|
+
onSuccess: (_, variables) => {
|
|
3261
|
+
invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
|
|
1531
3262
|
},
|
|
1532
3263
|
})
|
|
1533
3264
|
}
|
|
@@ -1551,9 +3282,8 @@ export function useResumeCronJob() {
|
|
|
1551
3282
|
errorMessage: 'Failed to resume CronJob',
|
|
1552
3283
|
successMessage: 'CronJob resumed',
|
|
1553
3284
|
},
|
|
1554
|
-
onSuccess: () => {
|
|
1555
|
-
queryClient.
|
|
1556
|
-
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
3285
|
+
onSuccess: (_, variables) => {
|
|
3286
|
+
invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
|
|
1557
3287
|
},
|
|
1558
3288
|
})
|
|
1559
3289
|
}
|
|
@@ -1567,10 +3297,21 @@ export function useRestartWorkload() {
|
|
|
1567
3297
|
const queryClient = useQueryClient()
|
|
1568
3298
|
|
|
1569
3299
|
return useMutation({
|
|
1570
|
-
mutationFn: async ({
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
3300
|
+
mutationFn: async ({
|
|
3301
|
+
kind,
|
|
3302
|
+
namespace,
|
|
3303
|
+
name,
|
|
3304
|
+
}: {
|
|
3305
|
+
kind: string
|
|
3306
|
+
namespace: string
|
|
3307
|
+
name: string
|
|
3308
|
+
}) => {
|
|
3309
|
+
const response = await apiFetch(
|
|
3310
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
|
|
3311
|
+
{
|
|
3312
|
+
method: 'POST',
|
|
3313
|
+
},
|
|
3314
|
+
)
|
|
1574
3315
|
if (!response.ok) {
|
|
1575
3316
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1576
3317
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -1582,7 +3323,9 @@ export function useRestartWorkload() {
|
|
|
1582
3323
|
successMessage: 'Workload restarting',
|
|
1583
3324
|
},
|
|
1584
3325
|
onSuccess: (_, variables) => {
|
|
1585
|
-
queryClient.invalidateQueries({
|
|
3326
|
+
queryClient.invalidateQueries({
|
|
3327
|
+
queryKey: ['resources', variables.kind],
|
|
3328
|
+
})
|
|
1586
3329
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1587
3330
|
},
|
|
1588
3331
|
})
|
|
@@ -1593,12 +3336,25 @@ export function useScaleWorkload() {
|
|
|
1593
3336
|
const queryClient = useQueryClient()
|
|
1594
3337
|
|
|
1595
3338
|
return useMutation({
|
|
1596
|
-
mutationFn: async ({
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
3339
|
+
mutationFn: async ({
|
|
3340
|
+
kind,
|
|
3341
|
+
namespace,
|
|
3342
|
+
name,
|
|
3343
|
+
replicas,
|
|
3344
|
+
}: {
|
|
3345
|
+
kind: string
|
|
3346
|
+
namespace: string
|
|
3347
|
+
name: string
|
|
3348
|
+
replicas: number
|
|
3349
|
+
}) => {
|
|
3350
|
+
const response = await apiFetch(
|
|
3351
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
|
|
3352
|
+
{
|
|
3353
|
+
method: 'POST',
|
|
3354
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3355
|
+
body: JSON.stringify({ replicas }),
|
|
3356
|
+
},
|
|
3357
|
+
)
|
|
1602
3358
|
if (!response.ok) {
|
|
1603
3359
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1604
3360
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -1610,8 +3366,12 @@ export function useScaleWorkload() {
|
|
|
1610
3366
|
successMessage: 'Workload scaled',
|
|
1611
3367
|
},
|
|
1612
3368
|
onSuccess: (_, variables) => {
|
|
1613
|
-
queryClient.invalidateQueries({
|
|
1614
|
-
|
|
3369
|
+
queryClient.invalidateQueries({
|
|
3370
|
+
queryKey: ['resources', variables.kind],
|
|
3371
|
+
})
|
|
3372
|
+
queryClient.invalidateQueries({
|
|
3373
|
+
queryKey: ['resource', variables.kind, variables.namespace, variables.name],
|
|
3374
|
+
})
|
|
1615
3375
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1616
3376
|
},
|
|
1617
3377
|
})
|
|
@@ -1631,7 +3391,12 @@ export interface WorkloadRevision {
|
|
|
1631
3391
|
template?: string // Pod template spec as YAML (for revision diff)
|
|
1632
3392
|
}
|
|
1633
3393
|
|
|
1634
|
-
export function useWorkloadRevisions(
|
|
3394
|
+
export function useWorkloadRevisions(
|
|
3395
|
+
kind: string,
|
|
3396
|
+
namespace: string,
|
|
3397
|
+
name: string,
|
|
3398
|
+
enabled = true,
|
|
3399
|
+
) {
|
|
1635
3400
|
return useQuery<WorkloadRevision[]>({
|
|
1636
3401
|
queryKey: ['workload-revisions', kind, namespace, name],
|
|
1637
3402
|
queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/revisions`),
|
|
@@ -1642,12 +3407,25 @@ export function useWorkloadRevisions(kind: string, namespace: string, name: stri
|
|
|
1642
3407
|
export function useRollbackWorkload() {
|
|
1643
3408
|
const queryClient = useQueryClient()
|
|
1644
3409
|
return useMutation({
|
|
1645
|
-
mutationFn: async ({
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
3410
|
+
mutationFn: async ({
|
|
3411
|
+
kind,
|
|
3412
|
+
namespace,
|
|
3413
|
+
name,
|
|
3414
|
+
revision,
|
|
3415
|
+
}: {
|
|
3416
|
+
kind: string
|
|
3417
|
+
namespace: string
|
|
3418
|
+
name: string
|
|
3419
|
+
revision: number
|
|
3420
|
+
}) => {
|
|
3421
|
+
const response = await apiFetch(
|
|
3422
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/rollback`,
|
|
3423
|
+
{
|
|
3424
|
+
method: 'POST',
|
|
3425
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3426
|
+
body: JSON.stringify({ revision }),
|
|
3427
|
+
},
|
|
3428
|
+
)
|
|
1651
3429
|
if (!response.ok) {
|
|
1652
3430
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1653
3431
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -1659,9 +3437,15 @@ export function useRollbackWorkload() {
|
|
|
1659
3437
|
successMessage: 'Rollback initiated',
|
|
1660
3438
|
},
|
|
1661
3439
|
onSuccess: (_, variables) => {
|
|
1662
|
-
queryClient.invalidateQueries({
|
|
1663
|
-
|
|
1664
|
-
|
|
3440
|
+
queryClient.invalidateQueries({
|
|
3441
|
+
queryKey: ['resources', variables.kind],
|
|
3442
|
+
})
|
|
3443
|
+
queryClient.invalidateQueries({
|
|
3444
|
+
queryKey: ['resource', variables.kind, variables.namespace, variables.name],
|
|
3445
|
+
})
|
|
3446
|
+
queryClient.invalidateQueries({
|
|
3447
|
+
queryKey: ['workload-revisions', variables.kind, variables.namespace, variables.name],
|
|
3448
|
+
})
|
|
1665
3449
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1666
3450
|
},
|
|
1667
3451
|
})
|
|
@@ -1691,7 +3475,9 @@ export function useCordonNode() {
|
|
|
1691
3475
|
},
|
|
1692
3476
|
onSuccess: (_, variables) => {
|
|
1693
3477
|
queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
|
|
1694
|
-
queryClient.invalidateQueries({
|
|
3478
|
+
queryClient.invalidateQueries({
|
|
3479
|
+
queryKey: ['resource', 'nodes', '', variables.name],
|
|
3480
|
+
})
|
|
1695
3481
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1696
3482
|
},
|
|
1697
3483
|
})
|
|
@@ -1717,7 +3503,9 @@ export function useUncordonNode() {
|
|
|
1717
3503
|
},
|
|
1718
3504
|
onSuccess: (_, variables) => {
|
|
1719
3505
|
queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
|
|
1720
|
-
queryClient.invalidateQueries({
|
|
3506
|
+
queryClient.invalidateQueries({
|
|
3507
|
+
queryKey: ['resource', 'nodes', '', variables.name],
|
|
3508
|
+
})
|
|
1721
3509
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1722
3510
|
},
|
|
1723
3511
|
})
|
|
@@ -1750,7 +3538,9 @@ export function useDrainNode() {
|
|
|
1750
3538
|
},
|
|
1751
3539
|
onSuccess: (data: { evictedPods?: string[]; errors?: string[] }, variables) => {
|
|
1752
3540
|
queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
|
|
1753
|
-
queryClient.invalidateQueries({
|
|
3541
|
+
queryClient.invalidateQueries({
|
|
3542
|
+
queryKey: ['resource', 'nodes', '', variables.name],
|
|
3543
|
+
})
|
|
1754
3544
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1755
3545
|
|
|
1756
3546
|
const evicted = data?.evictedPods?.length ?? 0
|
|
@@ -1771,72 +3561,181 @@ export function useDrainNode() {
|
|
|
1771
3561
|
// Helm API hooks
|
|
1772
3562
|
// ============================================================================
|
|
1773
3563
|
|
|
3564
|
+
function helmNamespaceParams(namespaces: string[] = []) {
|
|
3565
|
+
return namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
|
|
3566
|
+
}
|
|
3567
|
+
|
|
1774
3568
|
// List all Helm releases
|
|
1775
|
-
export function useHelmReleases(
|
|
1776
|
-
const params =
|
|
3569
|
+
export function useHelmReleases(namespaces: string[] = []) {
|
|
3570
|
+
const params = helmNamespaceParams(namespaces)
|
|
1777
3571
|
return useQuery<HelmRelease[]>({
|
|
1778
|
-
queryKey: ['helm-releases',
|
|
3572
|
+
queryKey: ['helm-releases', namespaces],
|
|
1779
3573
|
queryFn: () => fetchJSON(`/helm/releases${params}`),
|
|
1780
3574
|
staleTime: 30000, // 30 seconds
|
|
1781
3575
|
})
|
|
1782
3576
|
}
|
|
1783
3577
|
|
|
1784
3578
|
// Get details for a specific Helm release
|
|
1785
|
-
export function useHelmRelease(namespace: string, name: string) {
|
|
3579
|
+
export function useHelmRelease(namespace: string, name: string, options?: { enabled?: boolean }) {
|
|
1786
3580
|
return useQuery<HelmReleaseDetail>({
|
|
1787
3581
|
queryKey: ['helm-release', namespace, name],
|
|
1788
3582
|
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}`),
|
|
1789
|
-
enabled: Boolean(namespace && name),
|
|
3583
|
+
enabled: Boolean(namespace && name) && (options?.enabled ?? true),
|
|
1790
3584
|
staleTime: 5000,
|
|
1791
3585
|
refetchInterval: 10000, // Poll for live resource status updates (post-upgrade/rollback)
|
|
1792
3586
|
})
|
|
1793
3587
|
}
|
|
1794
3588
|
|
|
1795
|
-
// Get manifest for a Helm release (optionally at a specific revision).
|
|
1796
|
-
// `enabled` lets callers skip the query when the user's Cloud role
|
|
1797
|
-
// would 403 the read — saves a round-trip and avoids a transient
|
|
1798
|
-
// "error" state that the role-gated empty panel doesn't need.
|
|
1799
|
-
export function useHelmManifest(
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
3589
|
+
// Get manifest for a Helm release (optionally at a specific revision).
|
|
3590
|
+
// `enabled` lets callers skip the query when the user's Cloud role
|
|
3591
|
+
// would 403 the read — saves a round-trip and avoids a transient
|
|
3592
|
+
// "error" state that the role-gated empty panel doesn't need.
|
|
3593
|
+
export function useHelmManifest(
|
|
3594
|
+
namespace: string,
|
|
3595
|
+
name: string,
|
|
3596
|
+
revision?: number,
|
|
3597
|
+
enabled = true,
|
|
3598
|
+
) {
|
|
3599
|
+
const params = revision ? `?revision=${revision}` : ''
|
|
3600
|
+
return useQuery<string>({
|
|
3601
|
+
queryKey: ['helm-manifest', namespace, name, revision],
|
|
3602
|
+
queryFn: async () => {
|
|
3603
|
+
const response = await apiFetch(
|
|
3604
|
+
`${getApiBase()}/helm/releases/${namespace}/${name}/manifest${params}`,
|
|
3605
|
+
)
|
|
3606
|
+
if (!response.ok) {
|
|
3607
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
3608
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
3609
|
+
}
|
|
3610
|
+
return response.text()
|
|
3611
|
+
},
|
|
3612
|
+
enabled: Boolean(namespace && name && enabled),
|
|
3613
|
+
staleTime: 60000, // 1 minute
|
|
3614
|
+
})
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
// Get values for a Helm release. `enabled` see useHelmManifest.
|
|
3618
|
+
export function useHelmValues(
|
|
3619
|
+
namespace: string,
|
|
3620
|
+
name: string,
|
|
3621
|
+
allValues?: boolean,
|
|
3622
|
+
enabled = true,
|
|
3623
|
+
revision?: number,
|
|
3624
|
+
) {
|
|
3625
|
+
const params = new URLSearchParams()
|
|
3626
|
+
if (allValues) params.set('all', 'true')
|
|
3627
|
+
if (revision && revision > 0) params.set('revision', String(revision))
|
|
3628
|
+
const query = params.toString() ? `?${params.toString()}` : ''
|
|
3629
|
+
return useQuery<HelmValues>({
|
|
3630
|
+
queryKey: ['helm-values', namespace, name, allValues, revision],
|
|
3631
|
+
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${query}`),
|
|
3632
|
+
enabled: Boolean(namespace && name && enabled),
|
|
3633
|
+
staleTime: 60000,
|
|
3634
|
+
})
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
// Get diff between two revisions. `enabled` see useHelmManifest.
|
|
3638
|
+
export function useHelmManifestDiff(
|
|
3639
|
+
namespace: string,
|
|
3640
|
+
name: string,
|
|
3641
|
+
revision1: number,
|
|
3642
|
+
revision2: number,
|
|
3643
|
+
enabled = true,
|
|
3644
|
+
) {
|
|
3645
|
+
return useQuery<ManifestDiff>({
|
|
3646
|
+
queryKey: ['helm-diff', namespace, name, revision1, revision2],
|
|
3647
|
+
queryFn: () =>
|
|
3648
|
+
fetchJSON(
|
|
3649
|
+
`/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`,
|
|
3650
|
+
),
|
|
3651
|
+
enabled: Boolean(
|
|
3652
|
+
namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
|
|
3653
|
+
),
|
|
3654
|
+
staleTime: 60000,
|
|
3655
|
+
})
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
export function useHelmValuesDiff(
|
|
3659
|
+
namespace: string,
|
|
3660
|
+
name: string,
|
|
3661
|
+
revision1: number,
|
|
3662
|
+
revision2: number,
|
|
3663
|
+
allValues = false,
|
|
3664
|
+
enabled = true,
|
|
3665
|
+
) {
|
|
3666
|
+
return useQuery<ValuesDiff>({
|
|
3667
|
+
queryKey: ['helm-values-diff', namespace, name, revision1, revision2, allValues],
|
|
3668
|
+
queryFn: () => {
|
|
3669
|
+
const params = new URLSearchParams({
|
|
3670
|
+
revision1: String(revision1),
|
|
3671
|
+
revision2: String(revision2),
|
|
3672
|
+
})
|
|
3673
|
+
if (allValues) params.set('all', 'true')
|
|
3674
|
+
return fetchJSON(`/helm/releases/${namespace}/${name}/values/diff?${params.toString()}`)
|
|
3675
|
+
},
|
|
3676
|
+
enabled: Boolean(
|
|
3677
|
+
namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
|
|
3678
|
+
),
|
|
3679
|
+
staleTime: 60000,
|
|
3680
|
+
})
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
export function useHelmNotesDiff(
|
|
3684
|
+
namespace: string,
|
|
3685
|
+
name: string,
|
|
3686
|
+
revision1: number,
|
|
3687
|
+
revision2: number,
|
|
3688
|
+
enabled = true,
|
|
3689
|
+
) {
|
|
3690
|
+
return useQuery<NotesDiff>({
|
|
3691
|
+
queryKey: ['helm-notes-diff', namespace, name, revision1, revision2],
|
|
3692
|
+
queryFn: () =>
|
|
3693
|
+
fetchJSON(
|
|
3694
|
+
`/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`,
|
|
3695
|
+
),
|
|
3696
|
+
enabled: Boolean(
|
|
3697
|
+
namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
|
|
3698
|
+
),
|
|
3699
|
+
staleTime: 60000,
|
|
1813
3700
|
})
|
|
1814
3701
|
}
|
|
1815
3702
|
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
3703
|
+
export function useHelmHooksDiff(
|
|
3704
|
+
namespace: string,
|
|
3705
|
+
name: string,
|
|
3706
|
+
revision1: number,
|
|
3707
|
+
revision2: number,
|
|
3708
|
+
enabled = true,
|
|
3709
|
+
) {
|
|
3710
|
+
return useQuery<HooksDiff>({
|
|
3711
|
+
queryKey: ['helm-hooks-diff', namespace, name, revision1, revision2],
|
|
3712
|
+
queryFn: () =>
|
|
3713
|
+
fetchJSON(
|
|
3714
|
+
`/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`,
|
|
3715
|
+
),
|
|
3716
|
+
enabled: Boolean(
|
|
3717
|
+
namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
|
|
3718
|
+
),
|
|
1823
3719
|
staleTime: 60000,
|
|
1824
3720
|
})
|
|
1825
3721
|
}
|
|
1826
3722
|
|
|
1827
|
-
|
|
1828
|
-
export function useHelmManifestDiff(
|
|
3723
|
+
export function useHelmResourceDiff(
|
|
1829
3724
|
namespace: string,
|
|
1830
3725
|
name: string,
|
|
1831
3726
|
revision1: number,
|
|
1832
3727
|
revision2: number,
|
|
1833
3728
|
enabled = true,
|
|
1834
3729
|
) {
|
|
1835
|
-
return useQuery<
|
|
1836
|
-
queryKey: ['helm-diff', namespace, name, revision1, revision2],
|
|
3730
|
+
return useQuery<ResourceDiff>({
|
|
3731
|
+
queryKey: ['helm-resource-diff', namespace, name, revision1, revision2],
|
|
1837
3732
|
queryFn: () =>
|
|
1838
|
-
fetchJSON(
|
|
1839
|
-
|
|
3733
|
+
fetchJSON(
|
|
3734
|
+
`/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`,
|
|
3735
|
+
),
|
|
3736
|
+
enabled: Boolean(
|
|
3737
|
+
namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
|
|
3738
|
+
),
|
|
1840
3739
|
staleTime: 60000,
|
|
1841
3740
|
})
|
|
1842
3741
|
}
|
|
@@ -1852,11 +3751,24 @@ export function useHelmUpgradeInfo(namespace: string, name: string, enabled = tr
|
|
|
1852
3751
|
})
|
|
1853
3752
|
}
|
|
1854
3753
|
|
|
3754
|
+
// Available chart versions for a release (newest-first), for the upgrade dialog's
|
|
3755
|
+
// version picker. Empty when the source can't be resolved — the dialog then falls
|
|
3756
|
+
// back to the latest version from upgrade-info.
|
|
3757
|
+
export function useHelmReleaseVersions(namespace: string, name: string, enabled = true) {
|
|
3758
|
+
return useQuery<string[]>({
|
|
3759
|
+
queryKey: ['helm-release-versions', namespace, name],
|
|
3760
|
+
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/versions`),
|
|
3761
|
+
enabled: Boolean(namespace && name && enabled),
|
|
3762
|
+
staleTime: 30000,
|
|
3763
|
+
retry: false,
|
|
3764
|
+
})
|
|
3765
|
+
}
|
|
3766
|
+
|
|
1855
3767
|
// Batch check for upgrade availability (for list view)
|
|
1856
|
-
export function useHelmBatchUpgradeInfo(
|
|
1857
|
-
const params =
|
|
3768
|
+
export function useHelmBatchUpgradeInfo(namespaces: string[] = [], enabled = true) {
|
|
3769
|
+
const params = helmNamespaceParams(namespaces)
|
|
1858
3770
|
return useQuery<BatchUpgradeInfo>({
|
|
1859
|
-
queryKey: ['helm-batch-upgrade-info',
|
|
3771
|
+
queryKey: ['helm-batch-upgrade-info', namespaces],
|
|
1860
3772
|
queryFn: () => fetchJSON(`/helm/upgrade-check${params}`),
|
|
1861
3773
|
enabled,
|
|
1862
3774
|
staleTime: 30000, // 30 seconds - keep in sync with release list
|
|
@@ -1873,10 +3785,21 @@ export function useHelmRollback() {
|
|
|
1873
3785
|
const queryClient = useQueryClient()
|
|
1874
3786
|
|
|
1875
3787
|
return useMutation({
|
|
1876
|
-
mutationFn: async ({
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
3788
|
+
mutationFn: async ({
|
|
3789
|
+
namespace,
|
|
3790
|
+
name,
|
|
3791
|
+
revision,
|
|
3792
|
+
}: {
|
|
3793
|
+
namespace: string
|
|
3794
|
+
name: string
|
|
3795
|
+
revision: number
|
|
3796
|
+
}) => {
|
|
3797
|
+
const response = await apiFetch(
|
|
3798
|
+
`${getApiBase()}/helm/releases/${namespace}/${name}/rollback?revision=${revision}`,
|
|
3799
|
+
{
|
|
3800
|
+
method: 'POST',
|
|
3801
|
+
},
|
|
3802
|
+
)
|
|
1880
3803
|
if (!response.ok) {
|
|
1881
3804
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1882
3805
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -1889,7 +3812,9 @@ export function useHelmRollback() {
|
|
|
1889
3812
|
},
|
|
1890
3813
|
onSuccess: (_, variables) => {
|
|
1891
3814
|
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
1892
|
-
queryClient.invalidateQueries({
|
|
3815
|
+
queryClient.invalidateQueries({
|
|
3816
|
+
queryKey: ['helm-release', variables.namespace, variables.name],
|
|
3817
|
+
})
|
|
1893
3818
|
},
|
|
1894
3819
|
})
|
|
1895
3820
|
}
|
|
@@ -1967,30 +3892,49 @@ function streamHelmProgress(
|
|
|
1967
3892
|
|
|
1968
3893
|
if (data.type === 'complete') {
|
|
1969
3894
|
resolve(data)
|
|
3895
|
+
return
|
|
1970
3896
|
} else if (data.type === 'error') {
|
|
1971
3897
|
reject(new Error(data.message || failureLabel))
|
|
3898
|
+
return
|
|
1972
3899
|
}
|
|
1973
|
-
} catch {
|
|
1974
|
-
|
|
3900
|
+
} catch (err) {
|
|
3901
|
+
reject(
|
|
3902
|
+
err instanceof Error ? err : new Error(`${failureLabel}: invalid progress event`),
|
|
3903
|
+
)
|
|
3904
|
+
return
|
|
1975
3905
|
}
|
|
1976
3906
|
}
|
|
1977
3907
|
}
|
|
1978
3908
|
}
|
|
3909
|
+
|
|
3910
|
+
reject(new Error(`${failureLabel}: stream ended before completion`))
|
|
1979
3911
|
})
|
|
1980
3912
|
.catch(reject)
|
|
1981
3913
|
})
|
|
1982
3914
|
}
|
|
1983
3915
|
|
|
1984
|
-
//
|
|
3916
|
+
// When `values` is provided, the upgrade applies exactly those edited values
|
|
3917
|
+
// instead of carrying the release's prior values over blindly.
|
|
1985
3918
|
export function upgradeWithProgress(
|
|
1986
3919
|
namespace: string,
|
|
1987
3920
|
name: string,
|
|
1988
3921
|
version: string,
|
|
1989
|
-
|
|
3922
|
+
repositoryName: string | undefined,
|
|
3923
|
+
onProgress: (event: InstallProgressEvent) => void,
|
|
3924
|
+
values?: Record<string, unknown>,
|
|
1990
3925
|
): Promise<void> {
|
|
3926
|
+
const params = new URLSearchParams({ version })
|
|
3927
|
+
if (repositoryName) params.set('repository', repositoryName)
|
|
3928
|
+
const options: RequestInit = values
|
|
3929
|
+
? {
|
|
3930
|
+
method: 'POST',
|
|
3931
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3932
|
+
body: JSON.stringify({ values }),
|
|
3933
|
+
}
|
|
3934
|
+
: { method: 'POST' }
|
|
1991
3935
|
return streamHelmProgress(
|
|
1992
|
-
`${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream
|
|
1993
|
-
|
|
3936
|
+
`${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?${params.toString()}`,
|
|
3937
|
+
options,
|
|
1994
3938
|
onProgress,
|
|
1995
3939
|
'Upgrade failed',
|
|
1996
3940
|
).then(() => {})
|
|
@@ -2001,7 +3945,7 @@ export function rollbackWithProgress(
|
|
|
2001
3945
|
namespace: string,
|
|
2002
3946
|
name: string,
|
|
2003
3947
|
revision: number,
|
|
2004
|
-
onProgress: (event: InstallProgressEvent) => void
|
|
3948
|
+
onProgress: (event: InstallProgressEvent) => void,
|
|
2005
3949
|
): Promise<void> {
|
|
2006
3950
|
return streamHelmProgress(
|
|
2007
3951
|
`${getApiBase()}/helm/releases/${namespace}/${name}/rollback-stream?revision=${revision}`,
|
|
@@ -2011,15 +3955,29 @@ export function rollbackWithProgress(
|
|
|
2011
3955
|
).then(() => {})
|
|
2012
3956
|
}
|
|
2013
3957
|
|
|
2014
|
-
//
|
|
3958
|
+
// When `version` is supplied, preview renders against that target chart version
|
|
3959
|
+
// instead of the release's current chart.
|
|
2015
3960
|
export function useHelmPreviewValues() {
|
|
2016
|
-
return useMutation<
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
3961
|
+
return useMutation<
|
|
3962
|
+
ValuesPreviewResponse,
|
|
3963
|
+
Error,
|
|
3964
|
+
{
|
|
3965
|
+
namespace: string
|
|
3966
|
+
name: string
|
|
3967
|
+
values: Record<string, unknown>
|
|
3968
|
+
version?: string
|
|
3969
|
+
repository?: string
|
|
3970
|
+
}
|
|
3971
|
+
>({
|
|
3972
|
+
mutationFn: async ({ namespace, name, values, version, repository }) => {
|
|
3973
|
+
const response = await apiFetch(
|
|
3974
|
+
`${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`,
|
|
3975
|
+
{
|
|
3976
|
+
method: 'POST',
|
|
3977
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3978
|
+
body: JSON.stringify({ values, version, repository }),
|
|
3979
|
+
},
|
|
3980
|
+
)
|
|
2023
3981
|
if (!response.ok) {
|
|
2024
3982
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2025
3983
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -2034,7 +3992,15 @@ export function useHelmApplyValues() {
|
|
|
2034
3992
|
const queryClient = useQueryClient()
|
|
2035
3993
|
|
|
2036
3994
|
return useMutation({
|
|
2037
|
-
mutationFn: async ({
|
|
3995
|
+
mutationFn: async ({
|
|
3996
|
+
namespace,
|
|
3997
|
+
name,
|
|
3998
|
+
values,
|
|
3999
|
+
}: {
|
|
4000
|
+
namespace: string
|
|
4001
|
+
name: string
|
|
4002
|
+
values: Record<string, unknown>
|
|
4003
|
+
}) => {
|
|
2038
4004
|
const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values`, {
|
|
2039
4005
|
method: 'PUT',
|
|
2040
4006
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -2052,8 +4018,12 @@ export function useHelmApplyValues() {
|
|
|
2052
4018
|
},
|
|
2053
4019
|
onSuccess: (_, variables) => {
|
|
2054
4020
|
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
2055
|
-
queryClient.invalidateQueries({
|
|
2056
|
-
|
|
4021
|
+
queryClient.invalidateQueries({
|
|
4022
|
+
queryKey: ['helm-release', variables.namespace, variables.name],
|
|
4023
|
+
})
|
|
4024
|
+
queryClient.invalidateQueries({
|
|
4025
|
+
queryKey: ['helm-values', variables.namespace, variables.name],
|
|
4026
|
+
})
|
|
2057
4027
|
},
|
|
2058
4028
|
})
|
|
2059
4029
|
}
|
|
@@ -2070,29 +4040,103 @@ export function useHelmRepositories() {
|
|
|
2070
4040
|
})
|
|
2071
4041
|
}
|
|
2072
4042
|
|
|
4043
|
+
// Shared mutation fn + cache invalidation for the helm-repo
|
|
4044
|
+
// update endpoint. Hoisted so useUpdateRepository and
|
|
4045
|
+
// useUpdateRepositorySilent can't drift on URL, error parsing, or
|
|
4046
|
+
// invalidation keys — only the toast meta differs.
|
|
4047
|
+
async function updateRepositoryFn(repoName: string): Promise<unknown> {
|
|
4048
|
+
const response = await apiFetch(`${getApiBase()}/helm/repositories/${repoName}/update`, {
|
|
4049
|
+
method: 'POST',
|
|
4050
|
+
})
|
|
4051
|
+
if (!response.ok) {
|
|
4052
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
4053
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
4054
|
+
}
|
|
4055
|
+
return response.json()
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
function invalidateHelmAfterRepoUpdate(queryClient: ReturnType<typeof useQueryClient>) {
|
|
4059
|
+
queryClient.invalidateQueries({ queryKey: ['helm-repositories'] })
|
|
4060
|
+
queryClient.invalidateQueries({ queryKey: ['helm-charts'] })
|
|
4061
|
+
}
|
|
4062
|
+
|
|
2073
4063
|
// Update a repository index
|
|
2074
4064
|
export function useUpdateRepository() {
|
|
2075
4065
|
const queryClient = useQueryClient()
|
|
2076
|
-
|
|
2077
4066
|
return useMutation({
|
|
2078
|
-
mutationFn:
|
|
2079
|
-
const response = await apiFetch(`${getApiBase()}/helm/repositories/${repoName}/update`, {
|
|
2080
|
-
method: 'POST',
|
|
2081
|
-
})
|
|
2082
|
-
if (!response.ok) {
|
|
2083
|
-
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2084
|
-
throw new Error(error.error || `HTTP ${response.status}`)
|
|
2085
|
-
}
|
|
2086
|
-
return response.json()
|
|
2087
|
-
},
|
|
4067
|
+
mutationFn: updateRepositoryFn,
|
|
2088
4068
|
meta: {
|
|
2089
4069
|
errorMessage: 'Failed to update repository',
|
|
2090
4070
|
successMessage: 'Repository updated',
|
|
2091
4071
|
},
|
|
2092
|
-
onSuccess: () =>
|
|
2093
|
-
|
|
2094
|
-
|
|
4072
|
+
onSuccess: () => invalidateHelmAfterRepoUpdate(queryClient),
|
|
4073
|
+
})
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
// Same endpoint as useUpdateRepository but without meta — the
|
|
4077
|
+
// caller surfaces ONE aggregate toast for the whole batch, so the
|
|
4078
|
+
// global MutationCache must NOT fire a per-call toast (otherwise
|
|
4079
|
+
// N failures = N identical "Failed to update repository" toasts
|
|
4080
|
+
// with no repo name).
|
|
4081
|
+
export function useUpdateRepositorySilent() {
|
|
4082
|
+
const queryClient = useQueryClient()
|
|
4083
|
+
return useMutation({
|
|
4084
|
+
mutationFn: updateRepositoryFn,
|
|
4085
|
+
onSuccess: () => invalidateHelmAfterRepoUpdate(queryClient),
|
|
4086
|
+
})
|
|
4087
|
+
}
|
|
4088
|
+
|
|
4089
|
+
// Registered OCI chart sources (the OCI analog of `helm repo add`). Used to
|
|
4090
|
+
// track upgrades for the user's own OCI-published charts.
|
|
4091
|
+
export function useHelmOCISources() {
|
|
4092
|
+
return useQuery<string[]>({
|
|
4093
|
+
queryKey: ['helm-oci-sources'],
|
|
4094
|
+
queryFn: () => fetchJSON('/helm/oci-sources'),
|
|
4095
|
+
})
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
async function mutateOCISource(method: 'POST' | 'DELETE', source: string): Promise<string[]> {
|
|
4099
|
+
const response = await apiFetch(`${getApiBase()}/helm/oci-sources`, {
|
|
4100
|
+
method,
|
|
4101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4102
|
+
body: JSON.stringify({ source }),
|
|
4103
|
+
})
|
|
4104
|
+
if (!response.ok) {
|
|
4105
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
4106
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
4107
|
+
}
|
|
4108
|
+
return response.json()
|
|
4109
|
+
}
|
|
4110
|
+
|
|
4111
|
+
// Invalidate the upgrade-info queries so a newly-registered source is probed
|
|
4112
|
+
// immediately and "source not tracked" re-resolves.
|
|
4113
|
+
function invalidateHelmAfterSourceChange(queryClient: ReturnType<typeof useQueryClient>) {
|
|
4114
|
+
queryClient.invalidateQueries({ queryKey: ['helm-oci-sources'] })
|
|
4115
|
+
queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info'] })
|
|
4116
|
+
queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
export function useAddOCISource() {
|
|
4120
|
+
const queryClient = useQueryClient()
|
|
4121
|
+
return useMutation({
|
|
4122
|
+
mutationFn: (source: string) => mutateOCISource('POST', source),
|
|
4123
|
+
meta: {
|
|
4124
|
+
errorMessage: 'Failed to add chart source',
|
|
4125
|
+
successMessage: 'Chart source added',
|
|
4126
|
+
},
|
|
4127
|
+
onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
|
|
4128
|
+
})
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
export function useRemoveOCISource() {
|
|
4132
|
+
const queryClient = useQueryClient()
|
|
4133
|
+
return useMutation({
|
|
4134
|
+
mutationFn: (source: string) => mutateOCISource('DELETE', source),
|
|
4135
|
+
meta: {
|
|
4136
|
+
errorMessage: 'Failed to remove chart source',
|
|
4137
|
+
successMessage: 'Chart source removed',
|
|
2095
4138
|
},
|
|
4139
|
+
onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
|
|
2096
4140
|
})
|
|
2097
4141
|
}
|
|
2098
4142
|
|
|
@@ -2163,11 +4207,15 @@ export interface InstallProgressEvent {
|
|
|
2163
4207
|
// Install a chart with progress streaming via SSE
|
|
2164
4208
|
export function installChartWithProgress(
|
|
2165
4209
|
req: InstallChartRequest,
|
|
2166
|
-
onProgress: (event: InstallProgressEvent) => void
|
|
4210
|
+
onProgress: (event: InstallProgressEvent) => void,
|
|
2167
4211
|
): Promise<HelmRelease> {
|
|
2168
4212
|
return streamHelmProgress(
|
|
2169
4213
|
`${getApiBase()}/helm/releases/install-stream`,
|
|
2170
|
-
{
|
|
4214
|
+
{
|
|
4215
|
+
method: 'POST',
|
|
4216
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4217
|
+
body: JSON.stringify(req),
|
|
4218
|
+
},
|
|
2171
4219
|
onProgress,
|
|
2172
4220
|
'Install failed',
|
|
2173
4221
|
).then((event) => event.release as HelmRelease)
|
|
@@ -2183,8 +4231,14 @@ export type ArtifactHubSortOption = 'relevance' | 'stars' | 'last_updated'
|
|
|
2183
4231
|
// Search charts on ArtifactHub
|
|
2184
4232
|
export function useArtifactHubSearch(
|
|
2185
4233
|
query: string,
|
|
2186
|
-
options?: {
|
|
2187
|
-
|
|
4234
|
+
options?: {
|
|
4235
|
+
offset?: number
|
|
4236
|
+
limit?: number
|
|
4237
|
+
official?: boolean
|
|
4238
|
+
verified?: boolean
|
|
4239
|
+
sort?: ArtifactHubSortOption
|
|
4240
|
+
},
|
|
4241
|
+
enabled = true,
|
|
2188
4242
|
) {
|
|
2189
4243
|
const params = new URLSearchParams()
|
|
2190
4244
|
if (query) params.set('query', query)
|
|
@@ -2195,7 +4249,15 @@ export function useArtifactHubSearch(
|
|
|
2195
4249
|
if (options?.sort && options.sort !== 'relevance') params.set('sort', options.sort)
|
|
2196
4250
|
|
|
2197
4251
|
return useQuery<ArtifactHubSearchResult>({
|
|
2198
|
-
queryKey: [
|
|
4252
|
+
queryKey: [
|
|
4253
|
+
'artifacthub-search',
|
|
4254
|
+
query,
|
|
4255
|
+
options?.offset,
|
|
4256
|
+
options?.limit,
|
|
4257
|
+
options?.official,
|
|
4258
|
+
options?.verified,
|
|
4259
|
+
options?.sort,
|
|
4260
|
+
],
|
|
2199
4261
|
queryFn: () => fetchJSON(`/helm/artifacthub/search?${params.toString()}`),
|
|
2200
4262
|
enabled: enabled && query.length > 0,
|
|
2201
4263
|
staleTime: 60000, // 1 minute
|
|
@@ -2203,7 +4265,12 @@ export function useArtifactHubSearch(
|
|
|
2203
4265
|
}
|
|
2204
4266
|
|
|
2205
4267
|
// Get chart detail from ArtifactHub
|
|
2206
|
-
export function useArtifactHubChart(
|
|
4268
|
+
export function useArtifactHubChart(
|
|
4269
|
+
repoName: string,
|
|
4270
|
+
chartName: string,
|
|
4271
|
+
version?: string,
|
|
4272
|
+
enabled = true,
|
|
4273
|
+
) {
|
|
2207
4274
|
const path = version
|
|
2208
4275
|
? `/helm/artifacthub/charts/${repoName}/${chartName}/${version}`
|
|
2209
4276
|
: `/helm/artifacthub/charts/${repoName}/${chartName}`
|
|
@@ -2222,8 +4289,10 @@ export function useArtifactHubChart(repoName: string, chartName: string, version
|
|
|
2222
4289
|
|
|
2223
4290
|
interface GitOpsMutationConfig<TVariables> {
|
|
2224
4291
|
getPath: (variables: TVariables) => string
|
|
4292
|
+
getBody?: (variables: TVariables) => unknown
|
|
2225
4293
|
errorMessage: string
|
|
2226
|
-
successMessage
|
|
4294
|
+
successMessage?: string
|
|
4295
|
+
getSuccessMessage?: (data: GitOpsOperationResponse) => string
|
|
2227
4296
|
getInvalidateKeys: (variables: TVariables) => (string | undefined)[][]
|
|
2228
4297
|
}
|
|
2229
4298
|
|
|
@@ -2238,6 +4307,8 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
|
|
|
2238
4307
|
mutationFn: async (variables: TVariables): Promise<GitOpsOperationResponse> => {
|
|
2239
4308
|
const response = await apiFetch(`${getApiBase()}${config.getPath(variables)}`, {
|
|
2240
4309
|
method: 'POST',
|
|
4310
|
+
headers: config.getBody ? { 'Content-Type': 'application/json' } : undefined,
|
|
4311
|
+
body: config.getBody ? JSON.stringify(config.getBody(variables)) : undefined,
|
|
2241
4312
|
})
|
|
2242
4313
|
if (!response.ok) {
|
|
2243
4314
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
@@ -2249,9 +4320,10 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
|
|
|
2249
4320
|
errorMessage: config.errorMessage,
|
|
2250
4321
|
successMessage: config.successMessage,
|
|
2251
4322
|
},
|
|
2252
|
-
onSuccess: (
|
|
2253
|
-
config.
|
|
2254
|
-
|
|
4323
|
+
onSuccess: (data, variables) => {
|
|
4324
|
+
if (config.getSuccessMessage) showApiSuccess(config.getSuccessMessage(data))
|
|
4325
|
+
config.getInvalidateKeys(variables).forEach((key) =>
|
|
4326
|
+
queryClient.invalidateQueries({ queryKey: key }),
|
|
2255
4327
|
)
|
|
2256
4328
|
},
|
|
2257
4329
|
})
|
|
@@ -2260,16 +4332,76 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
|
|
|
2260
4332
|
|
|
2261
4333
|
// Common variable types
|
|
2262
4334
|
type FluxResourceVars = { kind: string; namespace: string; name: string }
|
|
4335
|
+
// ArgoAppVars identifies the target Application. Used by mutations that don't
|
|
4336
|
+
// take a body (terminate, suspend, resume, refresh).
|
|
2263
4337
|
type ArgoAppVars = { namespace: string; name: string }
|
|
4338
|
+
// ArgoSyncVars extends ArgoAppVars with the sync request body fields. Only
|
|
4339
|
+
// useArgoSync sends these — splitting the type prevents callers from passing
|
|
4340
|
+
// resources/revision/prune to mutations that would silently drop them.
|
|
4341
|
+
export type ArgoSyncVars = ArgoAppVars & {
|
|
4342
|
+
resources?: Array<{
|
|
4343
|
+
group?: string
|
|
4344
|
+
kind: string
|
|
4345
|
+
namespace?: string
|
|
4346
|
+
name: string
|
|
4347
|
+
}>
|
|
4348
|
+
revision?: string
|
|
4349
|
+
prune?: boolean
|
|
4350
|
+
dryRun?: boolean
|
|
4351
|
+
force?: boolean
|
|
4352
|
+
applyOnly?: boolean
|
|
4353
|
+
// Free-form Argo SyncOption strings, e.g. "Replace=true",
|
|
4354
|
+
// "ServerSideApply=true", "PruneLast=true". Caller is responsible for
|
|
4355
|
+
// spelling.
|
|
4356
|
+
syncOptions?: string[]
|
|
4357
|
+
}
|
|
4358
|
+
|
|
4359
|
+
export interface ArgoResourceValidationResult {
|
|
4360
|
+
outcome: 'succeeded' | 'failed' | 'inconclusive'
|
|
4361
|
+
message: string
|
|
4362
|
+
resource?: {
|
|
4363
|
+
group?: string
|
|
4364
|
+
kind: string
|
|
4365
|
+
namespace?: string
|
|
4366
|
+
name: string
|
|
4367
|
+
status?: string
|
|
4368
|
+
message?: string
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
export function buildArgoResourceSyncVars(namespace: string, name: string, resource: GitOpsInsightRef, opts: ArgoSyncOpts): ArgoSyncVars {
|
|
4373
|
+
return {
|
|
4374
|
+
namespace,
|
|
4375
|
+
name,
|
|
4376
|
+
...opts,
|
|
4377
|
+
resources: [{ group: resource.group, kind: resource.kind, namespace: resource.namespace, name: resource.name }],
|
|
4378
|
+
revision: undefined,
|
|
4379
|
+
prune: false,
|
|
4380
|
+
applyOnly: false,
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
// ArgoRollbackVars targets a specific Argo history entry by ID. Prune and
|
|
4385
|
+
// DryRun mirror the sync flags so the rollback dialog can offer the same
|
|
4386
|
+
// safety net.
|
|
4387
|
+
type ArgoRollbackVars = ArgoAppVars & {
|
|
4388
|
+
id: number
|
|
4389
|
+
prune?: boolean
|
|
4390
|
+
dryRun?: boolean
|
|
4391
|
+
}
|
|
2264
4392
|
|
|
2265
4393
|
// Standard invalidation patterns
|
|
2266
4394
|
const fluxInvalidateKeys = (v: FluxResourceVars) => [
|
|
2267
4395
|
['resources', v.kind],
|
|
2268
4396
|
['resource', v.kind, v.namespace, v.name],
|
|
4397
|
+
['gitops-tree', v.kind, v.namespace, v.name],
|
|
4398
|
+
['gitops-insights', v.kind, v.namespace, v.name],
|
|
2269
4399
|
]
|
|
2270
4400
|
const argoInvalidateKeys = (v: ArgoAppVars) => [
|
|
2271
4401
|
['resources', 'applications'],
|
|
2272
4402
|
['resource', 'applications', v.namespace, v.name],
|
|
4403
|
+
['gitops-tree', 'applications', v.namespace, v.name],
|
|
4404
|
+
['gitops-insights', 'applications', v.namespace, v.name],
|
|
2273
4405
|
]
|
|
2274
4406
|
|
|
2275
4407
|
// ============================================================================
|
|
@@ -2314,17 +4446,59 @@ export const useFluxSyncWithSource = createGitOpsMutation<FluxResourceVars>({
|
|
|
2314
4446
|
// ArgoCD API hooks
|
|
2315
4447
|
// ============================================================================
|
|
2316
4448
|
|
|
2317
|
-
export const useArgoSync = createGitOpsMutation<
|
|
4449
|
+
export const useArgoSync = createGitOpsMutation<ArgoSyncVars>({
|
|
2318
4450
|
getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/sync`,
|
|
4451
|
+
getBody: (v) => ({
|
|
4452
|
+
resources: v.resources,
|
|
4453
|
+
revision: v.revision,
|
|
4454
|
+
prune: v.prune,
|
|
4455
|
+
dryRun: v.dryRun,
|
|
4456
|
+
force: v.force,
|
|
4457
|
+
applyOnly: v.applyOnly,
|
|
4458
|
+
syncOptions: v.syncOptions,
|
|
4459
|
+
}),
|
|
2319
4460
|
errorMessage: 'Failed to trigger sync',
|
|
2320
4461
|
successMessage: 'Sync initiated',
|
|
2321
4462
|
getInvalidateKeys: argoInvalidateKeys,
|
|
2322
4463
|
})
|
|
2323
4464
|
|
|
4465
|
+
export function useArgoResourceValidation() {
|
|
4466
|
+
const queryClient = useQueryClient()
|
|
4467
|
+
return useMutation<ArgoResourceValidationResult, Error, ArgoSyncVars>({
|
|
4468
|
+
mutationFn: async (variables) => {
|
|
4469
|
+
const response = await apiFetch(`${getApiBase()}/argo/applications/${variables.namespace}/${variables.name}/validate-resource`, {
|
|
4470
|
+
method: 'POST',
|
|
4471
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4472
|
+
body: JSON.stringify({
|
|
4473
|
+
resources: variables.resources,
|
|
4474
|
+
force: variables.force,
|
|
4475
|
+
syncOptions: variables.syncOptions,
|
|
4476
|
+
}),
|
|
4477
|
+
})
|
|
4478
|
+
if (!response.ok) {
|
|
4479
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
4480
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
4481
|
+
}
|
|
4482
|
+
return response.json() as Promise<ArgoResourceValidationResult>
|
|
4483
|
+
},
|
|
4484
|
+
onSettled: (_, __, variables) => {
|
|
4485
|
+
argoInvalidateKeys(variables).forEach(key => queryClient.invalidateQueries({ queryKey: key }))
|
|
4486
|
+
},
|
|
4487
|
+
})
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4490
|
+
export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
|
|
4491
|
+
getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/rollback`,
|
|
4492
|
+
getBody: (v) => ({ id: v.id, prune: v.prune, dryRun: v.dryRun }),
|
|
4493
|
+
errorMessage: 'Failed to roll back application',
|
|
4494
|
+
successMessage: 'Rollback initiated',
|
|
4495
|
+
getInvalidateKeys: argoInvalidateKeys,
|
|
4496
|
+
})
|
|
4497
|
+
|
|
2324
4498
|
export const useArgoTerminate = createGitOpsMutation<ArgoAppVars>({
|
|
2325
4499
|
getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/terminate`,
|
|
2326
4500
|
errorMessage: 'Failed to terminate sync',
|
|
2327
|
-
|
|
4501
|
+
getSuccessMessage: (data) => data.message,
|
|
2328
4502
|
getInvalidateKeys: argoInvalidateKeys,
|
|
2329
4503
|
})
|
|
2330
4504
|
|
|
@@ -2347,11 +4521,22 @@ export function useArgoRefresh() {
|
|
|
2347
4521
|
const queryClient = useQueryClient()
|
|
2348
4522
|
|
|
2349
4523
|
return useMutation({
|
|
2350
|
-
mutationFn: async ({
|
|
4524
|
+
mutationFn: async ({
|
|
4525
|
+
namespace,
|
|
4526
|
+
name,
|
|
4527
|
+
hard = false,
|
|
4528
|
+
}: {
|
|
4529
|
+
namespace: string
|
|
4530
|
+
name: string
|
|
4531
|
+
hard?: boolean
|
|
4532
|
+
}) => {
|
|
2351
4533
|
const params = hard ? '?type=hard' : ''
|
|
2352
|
-
const response = await apiFetch(
|
|
2353
|
-
|
|
2354
|
-
|
|
4534
|
+
const response = await apiFetch(
|
|
4535
|
+
`${getApiBase()}/argo/applications/${namespace}/${name}/refresh${params}`,
|
|
4536
|
+
{
|
|
4537
|
+
method: 'POST',
|
|
4538
|
+
},
|
|
4539
|
+
)
|
|
2355
4540
|
if (!response.ok) {
|
|
2356
4541
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2357
4542
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -2363,8 +4548,13 @@ export function useArgoRefresh() {
|
|
|
2363
4548
|
successMessage: 'Application refreshed',
|
|
2364
4549
|
},
|
|
2365
4550
|
onSuccess: (_, variables) => {
|
|
2366
|
-
|
|
2367
|
-
|
|
4551
|
+
// Match the standard Argo invalidation set so the GitOps detail page
|
|
4552
|
+
// (insights strip, resource tree) refetches after Refresh / Hard
|
|
4553
|
+
// Refresh — without these two extra keys the user clicks Refresh and
|
|
4554
|
+
// sees stale insight/tree data until the next staleTime tick.
|
|
4555
|
+
argoInvalidateKeys(variables).forEach((key) =>
|
|
4556
|
+
queryClient.invalidateQueries({ queryKey: key }),
|
|
4557
|
+
)
|
|
2368
4558
|
},
|
|
2369
4559
|
})
|
|
2370
4560
|
}
|
|
@@ -2421,7 +4611,9 @@ export function useSwitchContext() {
|
|
|
2421
4611
|
} catch (error) {
|
|
2422
4612
|
clearTimeout(timeoutId)
|
|
2423
4613
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
2424
|
-
throw new Error('Context switch timed out. The cluster may be unreachable.'
|
|
4614
|
+
throw new Error('Context switch timed out. The cluster may be unreachable.', {
|
|
4615
|
+
cause: error,
|
|
4616
|
+
})
|
|
2425
4617
|
}
|
|
2426
4618
|
throw error
|
|
2427
4619
|
}
|
|
@@ -2441,6 +4633,142 @@ export function useSwitchContext() {
|
|
|
2441
4633
|
})
|
|
2442
4634
|
}
|
|
2443
4635
|
|
|
4636
|
+
// ============================================================================
|
|
4637
|
+
// Active namespace switcher
|
|
4638
|
+
// ============================================================================
|
|
4639
|
+
|
|
4640
|
+
export interface NamespaceScope {
|
|
4641
|
+
actives: string[]
|
|
4642
|
+
kubeconfigNamespace: string
|
|
4643
|
+
/**
|
|
4644
|
+
* 'cluster-wide' — no per-user pick; user can list across namespaces.
|
|
4645
|
+
* 'namespace' — per-user view filter pinned to one or more namespaces.
|
|
4646
|
+
* 'restricted' — user can't list namespaces and isn't pinned to any.
|
|
4647
|
+
*/
|
|
4648
|
+
mode: 'cluster-wide' | 'namespace' | 'restricted'
|
|
4649
|
+
accessibleNamespaces: string[]
|
|
4650
|
+
/** false when accessibleNamespaces is a best-effort short list (no list perm). */
|
|
4651
|
+
authoritative: boolean
|
|
4652
|
+
/** false when clearing would leave no usable namespace fallback. */
|
|
4653
|
+
canClearNamespace: boolean
|
|
4654
|
+
/** true when the backend informer cache is pinned to a namespace. */
|
|
4655
|
+
cacheScoped: boolean
|
|
4656
|
+
cacheScopeNamespace?: string
|
|
4657
|
+
/** true when this client may rebuild the local cache for another namespace. */
|
|
4658
|
+
namespaceRescope: boolean
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
export function useNamespaceScope() {
|
|
4662
|
+
return useQuery<NamespaceScope>({
|
|
4663
|
+
queryKey: ['namespace-scope'],
|
|
4664
|
+
queryFn: () => fetchJSON('/cluster/namespace-scope'),
|
|
4665
|
+
staleTime: 30000,
|
|
4666
|
+
})
|
|
4667
|
+
}
|
|
4668
|
+
|
|
4669
|
+
const NAMESPACE_SWITCH_TIMEOUT = 5000
|
|
4670
|
+
const NAMESPACE_RESCOPE_TIMEOUT = 120000
|
|
4671
|
+
|
|
4672
|
+
export function debugNamespaceLog(label: string, payload?: Record<string, unknown>) {
|
|
4673
|
+
if (typeof window === 'undefined') return
|
|
4674
|
+
const enabled = window.localStorage.getItem('radar:debug:namespaces')
|
|
4675
|
+
if (enabled !== '1' && enabled !== 'true') return
|
|
4676
|
+
console.log(`[namespace-debug] ${label}`, {
|
|
4677
|
+
t: Math.round(performance.now()),
|
|
4678
|
+
href: window.location.href,
|
|
4679
|
+
...payload,
|
|
4680
|
+
})
|
|
4681
|
+
}
|
|
4682
|
+
|
|
4683
|
+
export function useSetActiveNamespace() {
|
|
4684
|
+
const queryClient = useQueryClient()
|
|
4685
|
+
return useMutation<NamespaceScope, Error, { namespaces: string[] }>({
|
|
4686
|
+
meta: {
|
|
4687
|
+
// Surface 403s (RBAC drift, denied bookmark) and network errors via the
|
|
4688
|
+
// global toast. Without this, App.tsx call sites that mutate without
|
|
4689
|
+
// their own onError (bookmark reconciliation, back-nav, topology
|
|
4690
|
+
// maximize/clear, command palette) silently revert when the scope
|
|
4691
|
+
// refetches and the mirror effect overwrites local state.
|
|
4692
|
+
errorMessage: 'Failed to update namespace selection',
|
|
4693
|
+
},
|
|
4694
|
+
mutationFn: async ({ namespaces }) => {
|
|
4695
|
+
debugNamespaceLog('mutation:start', { namespaces })
|
|
4696
|
+
const controller = new AbortController()
|
|
4697
|
+
const currentScope = queryClient.getQueryData<NamespaceScope>(['namespace-scope'])
|
|
4698
|
+
// cacheScoped is a stable per-process property (the server's --namespace-scope
|
|
4699
|
+
// flag). If the scope query is missing/stale we can't yet tell a cheap
|
|
4700
|
+
// view-filter change from a cache-rebuilding rescope, so bias to the long
|
|
4701
|
+
// timeout — only a confirmed non-scoped session gets the fast switch timeout.
|
|
4702
|
+
// Aborting a real rebuild at 5s surfaces a spurious failure while the server
|
|
4703
|
+
// keeps going.
|
|
4704
|
+
const isRescope = currentScope?.cacheScoped !== false
|
|
4705
|
+
const timeoutMs = isRescope ? NAMESPACE_RESCOPE_TIMEOUT : NAMESPACE_SWITCH_TIMEOUT
|
|
4706
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
|
4707
|
+
const startedAt = performance.now()
|
|
4708
|
+
try {
|
|
4709
|
+
const response = await apiFetch(`${getApiBase()}/cluster/namespace`, {
|
|
4710
|
+
method: 'POST',
|
|
4711
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4712
|
+
body: JSON.stringify({ namespaces }),
|
|
4713
|
+
signal: controller.signal,
|
|
4714
|
+
})
|
|
4715
|
+
clearTimeout(timeoutId)
|
|
4716
|
+
debugNamespaceLog('mutation:response', {
|
|
4717
|
+
namespaces,
|
|
4718
|
+
status: response.status,
|
|
4719
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
4720
|
+
})
|
|
4721
|
+
if (!response.ok) {
|
|
4722
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
4723
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
4724
|
+
}
|
|
4725
|
+
return response.json()
|
|
4726
|
+
} catch (error) {
|
|
4727
|
+
clearTimeout(timeoutId)
|
|
4728
|
+
debugNamespaceLog('mutation:error', {
|
|
4729
|
+
namespaces,
|
|
4730
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
4731
|
+
error: error instanceof Error ? error.message : String(error),
|
|
4732
|
+
})
|
|
4733
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
4734
|
+
throw new Error(
|
|
4735
|
+
isRescope
|
|
4736
|
+
? 'Namespace rescope timed out. The cluster may still be loading.'
|
|
4737
|
+
: 'Namespace switch timed out. The cluster may be unreachable.',
|
|
4738
|
+
{ cause: error },
|
|
4739
|
+
)
|
|
4740
|
+
}
|
|
4741
|
+
throw error
|
|
4742
|
+
}
|
|
4743
|
+
},
|
|
4744
|
+
onSuccess: (scope) => {
|
|
4745
|
+
debugNamespaceLog('mutation:success-before-scope-cache-write', {
|
|
4746
|
+
actives: scope.actives,
|
|
4747
|
+
mode: scope.mode,
|
|
4748
|
+
accessibleCount: scope.accessibleNamespaces.length,
|
|
4749
|
+
})
|
|
4750
|
+
if (scope.cacheScoped) {
|
|
4751
|
+
queryClient.removeQueries({
|
|
4752
|
+
predicate: (query) => query.queryKey[0] !== 'namespace-scope',
|
|
4753
|
+
})
|
|
4754
|
+
}
|
|
4755
|
+
queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
|
|
4756
|
+
if (scope.cacheScoped) {
|
|
4757
|
+
queryClient.invalidateQueries()
|
|
4758
|
+
}
|
|
4759
|
+
debugNamespaceLog('mutation:success-after-scope-cache-write')
|
|
4760
|
+
},
|
|
4761
|
+
onError: () => {
|
|
4762
|
+
// A failed switch can leave the server's stored pick out of sync
|
|
4763
|
+
// with the cached scope (network timeout after the server wrote;
|
|
4764
|
+
// partial mutation). Refetch so the displayed picker matches what
|
|
4765
|
+
// the server actually persisted instead of what we assumed.
|
|
4766
|
+
debugNamespaceLog('mutation:on-error-invalidate-scope')
|
|
4767
|
+
queryClient.invalidateQueries({ queryKey: ['namespace-scope'] })
|
|
4768
|
+
},
|
|
4769
|
+
})
|
|
4770
|
+
}
|
|
4771
|
+
|
|
2444
4772
|
// ============================================================================
|
|
2445
4773
|
// Image Filesystem Inspection
|
|
2446
4774
|
// ============================================================================
|
|
@@ -2453,7 +4781,7 @@ export function useImageMetadata(
|
|
|
2453
4781
|
namespace: string,
|
|
2454
4782
|
podName: string,
|
|
2455
4783
|
pullSecrets: string[],
|
|
2456
|
-
enabled = true
|
|
4784
|
+
enabled = true,
|
|
2457
4785
|
) {
|
|
2458
4786
|
const params = new URLSearchParams()
|
|
2459
4787
|
params.set('image', image)
|
|
@@ -2476,7 +4804,7 @@ export function useImageFilesystem(
|
|
|
2476
4804
|
namespace: string,
|
|
2477
4805
|
podName: string,
|
|
2478
4806
|
pullSecrets: string[],
|
|
2479
|
-
enabled = true
|
|
4807
|
+
enabled = true,
|
|
2480
4808
|
) {
|
|
2481
4809
|
const params = new URLSearchParams()
|
|
2482
4810
|
params.set('image', image)
|
|
@@ -2489,9 +4817,7 @@ export function useImageFilesystem(
|
|
|
2489
4817
|
return useQuery<ImageFilesystem>({
|
|
2490
4818
|
queryKey: ['image-filesystem', image, namespace, podName, pullSecrets.join(',')],
|
|
2491
4819
|
// Use skipToken to completely prevent the query from running when disabled
|
|
2492
|
-
queryFn: shouldFetch
|
|
2493
|
-
? () => fetchJSON(`/images/inspect?${params.toString()}`)
|
|
2494
|
-
: skipToken,
|
|
4820
|
+
queryFn: shouldFetch ? () => fetchJSON(`/images/inspect?${params.toString()}`) : skipToken,
|
|
2495
4821
|
staleTime: 300000, // 5 minutes - image content doesn't change
|
|
2496
4822
|
retry: false, // Don't retry on auth errors
|
|
2497
4823
|
})
|
|
@@ -2515,6 +4841,44 @@ export interface WorkloadLogsResponse {
|
|
|
2515
4841
|
timestamp: string
|
|
2516
4842
|
content: string
|
|
2517
4843
|
}[]
|
|
4844
|
+
emptyReason?: string
|
|
4845
|
+
emptyMessage?: string
|
|
4846
|
+
command?: string
|
|
4847
|
+
}
|
|
4848
|
+
|
|
4849
|
+
export interface WorkloadRun {
|
|
4850
|
+
kind: string
|
|
4851
|
+
namespace: string
|
|
4852
|
+
name: string
|
|
4853
|
+
phase: string
|
|
4854
|
+
active: boolean
|
|
4855
|
+
startedAt?: string
|
|
4856
|
+
finishedAt?: string
|
|
4857
|
+
scheduledAt?: string
|
|
4858
|
+
trigger?: 'manual' | 'schedule' | string
|
|
4859
|
+
message?: string
|
|
4860
|
+
succeeded?: number
|
|
4861
|
+
failed?: number
|
|
4862
|
+
running?: number
|
|
4863
|
+
desired?: number
|
|
4864
|
+
parallelism?: number
|
|
4865
|
+
progress?: string
|
|
4866
|
+
template?: string
|
|
4867
|
+
launcher?: {
|
|
4868
|
+
kind: string
|
|
4869
|
+
namespace?: string
|
|
4870
|
+
name: string
|
|
4871
|
+
group?: string
|
|
4872
|
+
}
|
|
4873
|
+
podTotal?: number
|
|
4874
|
+
podSucceeded?: number
|
|
4875
|
+
podFailed?: number
|
|
4876
|
+
podRunning?: number
|
|
4877
|
+
podPending?: number
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4880
|
+
export interface WorkloadRunsResponse {
|
|
4881
|
+
runs: WorkloadRun[]
|
|
2518
4882
|
}
|
|
2519
4883
|
|
|
2520
4884
|
// Fetch pods for a workload
|
|
@@ -2527,6 +4891,31 @@ export function useWorkloadPods(kind: string, namespace: string, name: string) {
|
|
|
2527
4891
|
})
|
|
2528
4892
|
}
|
|
2529
4893
|
|
|
4894
|
+
export function useWorkloadRuns(
|
|
4895
|
+
kind: string,
|
|
4896
|
+
namespace: string,
|
|
4897
|
+
name: string,
|
|
4898
|
+
enabled = true,
|
|
4899
|
+
options?: { refetchActive?: boolean; clusterScoped?: boolean },
|
|
4900
|
+
) {
|
|
4901
|
+
const clusterScoped = options?.clusterScoped ?? false
|
|
4902
|
+
const ns = clusterScoped ? '_' : namespace
|
|
4903
|
+
const params = new URLSearchParams()
|
|
4904
|
+
if (clusterScoped) params.set('clusterScoped', 'true')
|
|
4905
|
+
const queryString = params.toString()
|
|
4906
|
+
|
|
4907
|
+
return useQuery<WorkloadRunsResponse>({
|
|
4908
|
+
queryKey: ['workload-runs', kind, namespace, name, clusterScoped],
|
|
4909
|
+
queryFn: () =>
|
|
4910
|
+
fetchJSON(`/workloads/${kind}/${ns}/${name}/runs${queryString ? `?${queryString}` : ''}`),
|
|
4911
|
+
enabled: enabled && Boolean(kind && name && (namespace || clusterScoped)),
|
|
4912
|
+
staleTime: 10000,
|
|
4913
|
+
refetchInterval: options?.refetchActive
|
|
4914
|
+
? (query) => (query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000)
|
|
4915
|
+
: false,
|
|
4916
|
+
})
|
|
4917
|
+
}
|
|
4918
|
+
|
|
2530
4919
|
// Fetch logs for a workload (non-streaming)
|
|
2531
4920
|
export function useWorkloadLogs(
|
|
2532
4921
|
kind: string,
|
|
@@ -2536,7 +4925,7 @@ export function useWorkloadLogs(
|
|
|
2536
4925
|
container?: string
|
|
2537
4926
|
tailLines?: number
|
|
2538
4927
|
sinceSeconds?: number
|
|
2539
|
-
}
|
|
4928
|
+
},
|
|
2540
4929
|
) {
|
|
2541
4930
|
const params = new URLSearchParams()
|
|
2542
4931
|
if (options?.container) params.set('container', options.container)
|
|
@@ -2545,8 +4934,19 @@ export function useWorkloadLogs(
|
|
|
2545
4934
|
const queryString = params.toString()
|
|
2546
4935
|
|
|
2547
4936
|
return useQuery<WorkloadLogsResponse>({
|
|
2548
|
-
queryKey: [
|
|
2549
|
-
|
|
4937
|
+
queryKey: [
|
|
4938
|
+
'workload-logs',
|
|
4939
|
+
kind,
|
|
4940
|
+
namespace,
|
|
4941
|
+
name,
|
|
4942
|
+
options?.container,
|
|
4943
|
+
options?.tailLines,
|
|
4944
|
+
options?.sinceSeconds,
|
|
4945
|
+
],
|
|
4946
|
+
queryFn: () =>
|
|
4947
|
+
fetchJSON(
|
|
4948
|
+
`/workloads/${kind}/${namespace}/${name}/logs${queryString ? `?${queryString}` : ''}`,
|
|
4949
|
+
),
|
|
2550
4950
|
enabled: Boolean(kind && namespace && name),
|
|
2551
4951
|
staleTime: 5000,
|
|
2552
4952
|
})
|
|
@@ -2561,7 +4961,7 @@ export function createWorkloadLogStream(
|
|
|
2561
4961
|
container?: string
|
|
2562
4962
|
tailLines?: number
|
|
2563
4963
|
sinceSeconds?: number
|
|
2564
|
-
}
|
|
4964
|
+
},
|
|
2565
4965
|
): EventSource {
|
|
2566
4966
|
const params = new URLSearchParams()
|
|
2567
4967
|
if (options?.container) params.set('container', options.container)
|
|
@@ -2569,9 +4969,12 @@ export function createWorkloadLogStream(
|
|
|
2569
4969
|
if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
|
|
2570
4970
|
const queryString = params.toString()
|
|
2571
4971
|
|
|
2572
|
-
return new EventSource(
|
|
2573
|
-
|
|
2574
|
-
|
|
4972
|
+
return new EventSource(
|
|
4973
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/logs/stream${queryString ? `?${queryString}` : ''}`,
|
|
4974
|
+
{
|
|
4975
|
+
withCredentials: getCredentialsMode() === 'include',
|
|
4976
|
+
},
|
|
4977
|
+
)
|
|
2575
4978
|
}
|
|
2576
4979
|
|
|
2577
4980
|
// ============================================================================
|
|
@@ -2603,6 +5006,59 @@ export interface DiagErrorEntry {
|
|
|
2603
5006
|
level: string
|
|
2604
5007
|
}
|
|
2605
5008
|
|
|
5009
|
+
export type DiagSyncPhase = 'not_started' | 'syncing_critical' | 'syncing_deferred' | 'complete'
|
|
5010
|
+
|
|
5011
|
+
export interface DiagInformerSyncStatus {
|
|
5012
|
+
kind: string
|
|
5013
|
+
key: string
|
|
5014
|
+
deferred: boolean
|
|
5015
|
+
synced: boolean
|
|
5016
|
+
syncedAt?: string
|
|
5017
|
+
items: number
|
|
5018
|
+
lastError?: string
|
|
5019
|
+
lastErrorAt?: string
|
|
5020
|
+
forbiddenSeen?: boolean
|
|
5021
|
+
}
|
|
5022
|
+
|
|
5023
|
+
export interface DiagCacheSyncStatus {
|
|
5024
|
+
phase: DiagSyncPhase
|
|
5025
|
+
syncStarted?: string
|
|
5026
|
+
elapsedSec: number
|
|
5027
|
+
criticalTotal: number
|
|
5028
|
+
criticalSynced: number
|
|
5029
|
+
deferredTotal: number
|
|
5030
|
+
deferredSynced: number
|
|
5031
|
+
informers: DiagInformerSyncStatus[]
|
|
5032
|
+
pendingCritical?: string[]
|
|
5033
|
+
pendingDeferred?: string[]
|
|
5034
|
+
promotedKinds?: string[]
|
|
5035
|
+
}
|
|
5036
|
+
|
|
5037
|
+
export interface DiagSampleWindow {
|
|
5038
|
+
count: number
|
|
5039
|
+
last: number
|
|
5040
|
+
min: number
|
|
5041
|
+
p50: number
|
|
5042
|
+
p95: number
|
|
5043
|
+
p99: number
|
|
5044
|
+
max: number
|
|
5045
|
+
}
|
|
5046
|
+
|
|
5047
|
+
export interface DiagPerfSnapshot {
|
|
5048
|
+
topology: {
|
|
5049
|
+
totalBuilds: number
|
|
5050
|
+
durationUs: DiagSampleWindow
|
|
5051
|
+
nodeCount: DiagSampleWindow
|
|
5052
|
+
edgeCount: DiagSampleWindow
|
|
5053
|
+
payloadBytes: DiagSampleWindow
|
|
5054
|
+
estimatedNodes: DiagSampleWindow
|
|
5055
|
+
}
|
|
5056
|
+
sse: {
|
|
5057
|
+
totalBroadcasts: number
|
|
5058
|
+
totalDrops: number
|
|
5059
|
+
}
|
|
5060
|
+
}
|
|
5061
|
+
|
|
2606
5062
|
export interface DiagnosticsSnapshot {
|
|
2607
5063
|
timestamp: string
|
|
2608
5064
|
radarVersion: string
|
|
@@ -2666,6 +5122,7 @@ export interface DiagnosticsSnapshot {
|
|
|
2666
5122
|
typedCount: number
|
|
2667
5123
|
dynamicCount: number
|
|
2668
5124
|
watchedCRDs: string[]
|
|
5125
|
+
syncStatus?: DiagCacheSyncStatus
|
|
2669
5126
|
}
|
|
2670
5127
|
prometheus?: {
|
|
2671
5128
|
connected: boolean
|
|
@@ -2696,6 +5153,7 @@ export interface DiagnosticsSnapshot {
|
|
|
2696
5153
|
sse?: {
|
|
2697
5154
|
connectedClients: number
|
|
2698
5155
|
}
|
|
5156
|
+
perf?: DiagPerfSnapshot
|
|
2699
5157
|
runtime?: {
|
|
2700
5158
|
heapMB: number
|
|
2701
5159
|
heapObjectsK: number
|
|
@@ -2711,6 +5169,7 @@ export interface DiagnosticsSnapshot {
|
|
|
2711
5169
|
debugEvents: boolean
|
|
2712
5170
|
mcpEnabled: boolean
|
|
2713
5171
|
hasPrometheusURL: boolean
|
|
5172
|
+
hasPrometheusHeaders: boolean
|
|
2714
5173
|
}
|
|
2715
5174
|
recentErrors?: DiagErrorEntry[]
|
|
2716
5175
|
totalErrorsRecorded?: number
|