@skyhook-io/radar-app 1.8.6 → 1.8.8
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 +12 -10
- package/src/App.tsx +278 -230
- package/src/RadarApp.tsx +91 -20
- 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.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1462 -246
- package/src/api/diagnose.ts +288 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +174 -70
- package/src/components/ContextSwitcher.tsx +13 -5
- package/src/components/applications/ApplicationsView.tsx +775 -80
- package/src/components/audit/AuditSettingsDialog.tsx +2 -2
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +103 -72
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +494 -229
- 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 +2 -2
- package/src/components/diagnose/AISettings.tsx +116 -0
- package/src/components/diagnose/DiagnoseContext.tsx +491 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +604 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1689 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- 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 +81 -14
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +2 -3
- package/src/components/helm/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
- package/src/components/helm/HelmView.tsx +2 -3
- package/src/components/helm/InstallWizard.tsx +3 -5
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/HomeView.tsx +17 -15
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +2 -2
- package/src/components/issues/IssuesPane.tsx +9 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/portforward/PortForwardButton.tsx +2 -2
- package/src/components/portforward/PortForwardManager.tsx +19 -14
- package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +320 -127
- package/src/components/resources/ImageFilesystemModal.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +2 -3
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
- package/src/components/resources/ResourcesView.tsx +13 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/index.ts +1 -0
- 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 +66 -116
- package/src/components/settings/SettingsDialog.tsx +1290 -283
- package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +81 -13
- package/src/components/timeline/TimelineView.tsx +842 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
- package/src/components/traffic/TrafficView.tsx +8 -4
- package/src/components/ui/CommandPalette.tsx +2 -2
- package/src/components/workload/WorkloadView.tsx +912 -245
- package/src/context/ConnectionContext.tsx +109 -11
- package/src/context/DiagnoseCustomization.tsx +42 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/index.css +162 -1
- package/src/index.ts +12 -0
- package/src/main.tsx +1 -1
- package/src/types/clusterLoadState.ts +33 -0
- package/src/utils/navigation.ts +10 -0
package/src/api/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react'
|
|
2
|
-
import type { AppRow } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import type { AppHistory, AppRow, ArgoSyncOpts } from '@skyhook-io/k8s-ui'
|
|
3
3
|
import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
|
|
4
4
|
import { showApiError, showApiSuccess } from '../components/ui/Toast'
|
|
5
5
|
import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
|
|
@@ -31,6 +31,9 @@ import type {
|
|
|
31
31
|
ArtifactHubChartDetail,
|
|
32
32
|
GitOpsResourceTree,
|
|
33
33
|
GitOpsInsight,
|
|
34
|
+
GitOpsInsightRef,
|
|
35
|
+
GitOpsResourceDiff,
|
|
36
|
+
ArgoRevisionMetadata,
|
|
34
37
|
} from '../types'
|
|
35
38
|
import type { GitOpsOperationResponse } from '../types/gitops'
|
|
36
39
|
import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
|
|
@@ -42,6 +45,9 @@ const DASHBOARD_REFRESH_INTERVAL_MS = 30_000
|
|
|
42
45
|
const AUDIT_REFRESH_INTERVAL_MS = 60_000
|
|
43
46
|
const ISSUES_REFRESH_INTERVAL_MS = 30_000
|
|
44
47
|
const COST_REFRESH_INTERVAL_MS = 60_000
|
|
48
|
+
const COST_DISCOVERY_RETRY_INTERVAL_MS = 5_000
|
|
49
|
+
export const COST_DISCOVERY_GRACE_MS = 30_000
|
|
50
|
+
const COST_TREND_REFRESH_INTERVAL_MS = 120_000
|
|
45
51
|
const CHANGES_REFRESH_INTERVAL_MS = 60_000
|
|
46
52
|
const APPLICATIONS_REFRESH_INTERVAL_MS = 60_000
|
|
47
53
|
|
|
@@ -54,12 +60,23 @@ export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<
|
|
|
54
60
|
for (const [k, v] of Object.entries(getAuthHeaders())) {
|
|
55
61
|
if (!headers.has(k)) headers.set(k, v)
|
|
56
62
|
}
|
|
57
|
-
return fetch(input, {
|
|
63
|
+
return fetch(input, {
|
|
64
|
+
credentials: getCredentialsMode(),
|
|
65
|
+
...init,
|
|
66
|
+
headers,
|
|
67
|
+
}).then(async (response) => {
|
|
58
68
|
const authPrefix = `${getBasename()}/auth`
|
|
59
69
|
if (response.status === 401 && !window.location.pathname.startsWith(authPrefix)) {
|
|
60
70
|
// Save current location so user returns to where they were after re-auth.
|
|
61
71
|
// Editor draft is auto-saved by EditableYamlView via sessionStorage.
|
|
62
|
-
try {
|
|
72
|
+
try {
|
|
73
|
+
sessionStorage.setItem(
|
|
74
|
+
'radar_return_path',
|
|
75
|
+
window.location.pathname + window.location.search,
|
|
76
|
+
)
|
|
77
|
+
} catch {
|
|
78
|
+
/* best-effort */
|
|
79
|
+
}
|
|
63
80
|
|
|
64
81
|
let authMode: string | undefined
|
|
65
82
|
try {
|
|
@@ -78,7 +95,11 @@ export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<
|
|
|
78
95
|
const lastReload = sessionStorage.getItem('radar_proxy_reload')
|
|
79
96
|
const now = Date.now()
|
|
80
97
|
if (!lastReload || now - parseInt(lastReload) > 5000) {
|
|
81
|
-
try {
|
|
98
|
+
try {
|
|
99
|
+
sessionStorage.setItem('radar_proxy_reload', String(now))
|
|
100
|
+
} catch {
|
|
101
|
+
/* best-effort */
|
|
102
|
+
}
|
|
82
103
|
window.location.reload()
|
|
83
104
|
}
|
|
84
105
|
}
|
|
@@ -107,11 +128,12 @@ const METRICS_API_GROUP_TOKENS = ['metrics', 'k8s', 'io'] as const
|
|
|
107
128
|
|
|
108
129
|
function mentionsMetricsAPIGroup(message: string): boolean {
|
|
109
130
|
const tokens = message.split(/[^a-z0-9]+/).filter(Boolean)
|
|
110
|
-
return tokens.some(
|
|
111
|
-
token
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
131
|
+
return tokens.some(
|
|
132
|
+
(token, index) =>
|
|
133
|
+
token === METRICS_API_GROUP_TOKENS[0] &&
|
|
134
|
+
tokens[index + 1] === METRICS_API_GROUP_TOKENS[1] &&
|
|
135
|
+
tokens[index + 2] === METRICS_API_GROUP_TOKENS[2],
|
|
136
|
+
)
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
function hasMetricsUnavailablePhrase(message: string): boolean {
|
|
@@ -134,18 +156,18 @@ export function isMetricsUnavailableError(error: unknown): boolean {
|
|
|
134
156
|
return [error.message, error.data?.error].some((message) => {
|
|
135
157
|
if (typeof message !== 'string') return false
|
|
136
158
|
const normalized = message.toLowerCase()
|
|
137
|
-
const hasMetricsSignal =
|
|
159
|
+
const hasMetricsSignal =
|
|
138
160
|
normalized.includes('metrics-server') ||
|
|
139
161
|
mentionsMetricsAPIGroup(normalized) ||
|
|
140
162
|
normalized.includes('pod metrics') ||
|
|
141
163
|
normalized.includes('node metrics')
|
|
142
|
-
)
|
|
143
164
|
return hasMetricsSignal && hasMetricsUnavailablePhrase(normalized)
|
|
144
165
|
})
|
|
145
166
|
}
|
|
146
167
|
|
|
147
|
-
export async function fetchJSON<T>(path: string,
|
|
148
|
-
const
|
|
168
|
+
export async function fetchJSON<T>(path: string, init?: RequestInit | AbortSignal): Promise<T> {
|
|
169
|
+
const requestInit = init instanceof AbortSignal ? { signal: init } : init
|
|
170
|
+
const response = await apiFetch(`${getApiBase()}${path}`, requestInit)
|
|
149
171
|
if (!response.ok) {
|
|
150
172
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
151
173
|
throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
|
|
@@ -209,7 +231,13 @@ export interface MetricSummary {
|
|
|
209
231
|
}
|
|
210
232
|
|
|
211
233
|
export interface DashboardResourceCounts {
|
|
212
|
-
pods: {
|
|
234
|
+
pods: {
|
|
235
|
+
total: number
|
|
236
|
+
running: number
|
|
237
|
+
pending: number
|
|
238
|
+
failed: number
|
|
239
|
+
succeeded: number
|
|
240
|
+
}
|
|
213
241
|
deployments: { total: number; available: number; unavailable: number }
|
|
214
242
|
statefulSets: WorkloadCount
|
|
215
243
|
daemonSets: WorkloadCount
|
|
@@ -292,7 +320,15 @@ export interface DashboardCRDCount {
|
|
|
292
320
|
}
|
|
293
321
|
|
|
294
322
|
// Re-export shared types from k8s-ui — single source of truth
|
|
295
|
-
import type {
|
|
323
|
+
import type {
|
|
324
|
+
AuditCardData,
|
|
325
|
+
AuditFinding,
|
|
326
|
+
ResourceGroup,
|
|
327
|
+
CheckMeta,
|
|
328
|
+
Check,
|
|
329
|
+
Issue,
|
|
330
|
+
IssueRecentChange,
|
|
331
|
+
} from '@skyhook-io/k8s-ui'
|
|
296
332
|
export type DashboardAudit = AuditCardData
|
|
297
333
|
export type { AuditFinding, ResourceGroup, CheckMeta, Check }
|
|
298
334
|
|
|
@@ -361,7 +397,11 @@ export interface DashboardResponse {
|
|
|
361
397
|
networkPolicyCoverage: DashboardNetworkPolicyCoverage | null
|
|
362
398
|
audit: DashboardAudit | null
|
|
363
399
|
gitopsControllers: DashboardGitOpsControllers | null
|
|
364
|
-
nodeVersionSkew: {
|
|
400
|
+
nodeVersionSkew: {
|
|
401
|
+
versions: Record<string, string[]>
|
|
402
|
+
minVersion: string
|
|
403
|
+
maxVersion: string
|
|
404
|
+
} | null
|
|
365
405
|
deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
|
|
366
406
|
partialData?: string[] // Critical kinds promoted at first paint that haven't yet finished syncing (live-filtered)
|
|
367
407
|
accessRestricted?: boolean // True when user has no namespace access (RBAC)
|
|
@@ -371,11 +411,12 @@ export interface DashboardCRDsResponse {
|
|
|
371
411
|
topCRDs: DashboardCRDCount[]
|
|
372
412
|
}
|
|
373
413
|
|
|
374
|
-
export function useDashboard(namespaces: string[] = []) {
|
|
414
|
+
export function useDashboard(namespaces: string[] = [], options?: { enabled?: boolean }) {
|
|
375
415
|
const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
|
|
376
416
|
return useQuery<DashboardResponse>({
|
|
377
417
|
queryKey: ['dashboard', namespaces],
|
|
378
418
|
queryFn: () => fetchJSON(`/dashboard${params}`),
|
|
419
|
+
enabled: options?.enabled ?? true,
|
|
379
420
|
staleTime: 15000, // 15 seconds
|
|
380
421
|
refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
|
|
381
422
|
})
|
|
@@ -436,7 +477,13 @@ export function useResourceAudit(kind: string, namespace: string, name: string)
|
|
|
436
477
|
// the "Operational Issues" section in the resource detail. Cluster-scoped
|
|
437
478
|
// resources pass "_" for namespace; namespaced ones also scope the scan via
|
|
438
479
|
// ?namespaces= for a cheap, bounded Compose.
|
|
439
|
-
export function useResourceIssues(
|
|
480
|
+
export function useResourceIssues(
|
|
481
|
+
kind: string,
|
|
482
|
+
group: string | undefined,
|
|
483
|
+
namespace: string,
|
|
484
|
+
name: string,
|
|
485
|
+
enabled = true,
|
|
486
|
+
) {
|
|
440
487
|
const clusterScoped = !namespace
|
|
441
488
|
const pathNs = clusterScoped ? '_' : encodeURIComponent(namespace)
|
|
442
489
|
const params = new URLSearchParams()
|
|
@@ -549,7 +596,8 @@ export interface OpenCostNamespaceCost {
|
|
|
549
596
|
idleCost?: number
|
|
550
597
|
}
|
|
551
598
|
|
|
552
|
-
export type CostUnavailableReason =
|
|
599
|
+
export type CostUnavailableReason =
|
|
600
|
+
'no_prometheus' | 'no_metrics' | 'query_error' | 'access_denied' | 'not_found'
|
|
553
601
|
|
|
554
602
|
export interface OpenCostSummary {
|
|
555
603
|
available: boolean
|
|
@@ -563,11 +611,41 @@ export interface OpenCostSummary {
|
|
|
563
611
|
namespaces?: OpenCostNamespaceCost[]
|
|
564
612
|
}
|
|
565
613
|
|
|
614
|
+
const noPrometheusFirstSeenAt = new Map<string, number>()
|
|
615
|
+
|
|
616
|
+
function costRefetchInterval(
|
|
617
|
+
defaultInterval: number | false = COST_REFRESH_INTERVAL_MS,
|
|
618
|
+
contextName?: string,
|
|
619
|
+
) {
|
|
620
|
+
return (query: {
|
|
621
|
+
queryHash?: string
|
|
622
|
+
queryKey?: unknown
|
|
623
|
+
state: {
|
|
624
|
+
data?: { available?: boolean; reason?: CostUnavailableReason }
|
|
625
|
+
dataUpdatedAt?: number
|
|
626
|
+
}
|
|
627
|
+
}) => {
|
|
628
|
+
const data = query.state.data
|
|
629
|
+
const queryID = `${contextName ?? 'unknown'}:${query.queryHash ?? JSON.stringify(query.queryKey ?? 'opencost')}`
|
|
630
|
+
if (data?.available === false && data.reason === 'no_prometheus') {
|
|
631
|
+
const now = Date.now()
|
|
632
|
+
const firstSeenAt = noPrometheusFirstSeenAt.get(queryID) ?? now
|
|
633
|
+
noPrometheusFirstSeenAt.set(queryID, firstSeenAt)
|
|
634
|
+
return now - firstSeenAt < COST_DISCOVERY_GRACE_MS
|
|
635
|
+
? COST_DISCOVERY_RETRY_INTERVAL_MS
|
|
636
|
+
: defaultInterval
|
|
637
|
+
}
|
|
638
|
+
noPrometheusFirstSeenAt.delete(queryID)
|
|
639
|
+
return defaultInterval
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
566
643
|
export function useOpenCostSummary() {
|
|
644
|
+
const clusterInfo = useClusterInfo()
|
|
567
645
|
return useQuery<OpenCostSummary>({
|
|
568
646
|
queryKey: ['opencost-summary'],
|
|
569
647
|
queryFn: () => fetchJSON('/opencost/summary'),
|
|
570
|
-
refetchInterval: COST_REFRESH_INTERVAL_MS,
|
|
648
|
+
refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
571
649
|
staleTime: 30000,
|
|
572
650
|
placeholderData: (prev) => prev, // Keep previous data visible during refetch
|
|
573
651
|
})
|
|
@@ -583,6 +661,10 @@ export interface OpenCostWorkloadCost {
|
|
|
583
661
|
replicas: number
|
|
584
662
|
cpuUsageCost?: number
|
|
585
663
|
memoryUsageCost?: number
|
|
664
|
+
cpuUsageAvailable: boolean
|
|
665
|
+
memoryUsageAvailable: boolean
|
|
666
|
+
cpuAllocationUse: number
|
|
667
|
+
memoryAllocationUse: number
|
|
586
668
|
efficiency?: number
|
|
587
669
|
idleCost?: number
|
|
588
670
|
}
|
|
@@ -595,11 +677,41 @@ export interface OpenCostWorkloadResponse {
|
|
|
595
677
|
}
|
|
596
678
|
|
|
597
679
|
export function useOpenCostWorkloads(namespace: string, options?: { enabled?: boolean }) {
|
|
680
|
+
const clusterInfo = useClusterInfo()
|
|
598
681
|
return useQuery<OpenCostWorkloadResponse>({
|
|
599
682
|
queryKey: ['opencost-workloads', namespace],
|
|
600
683
|
queryFn: () => fetchJSON(`/opencost/workloads?namespace=${encodeURIComponent(namespace)}`),
|
|
601
684
|
enabled: (options?.enabled ?? true) && Boolean(namespace),
|
|
685
|
+
refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
686
|
+
staleTime: 30000,
|
|
687
|
+
})
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export interface OpenCostWorkloadDetailResponse {
|
|
691
|
+
available: boolean
|
|
692
|
+
reason?: CostUnavailableReason
|
|
693
|
+
namespace: string
|
|
694
|
+
kind: string
|
|
695
|
+
name: string
|
|
696
|
+
current?: OpenCostWorkloadCost
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export function useOpenCostWorkload(
|
|
700
|
+
kind: string,
|
|
701
|
+
namespace: string,
|
|
702
|
+
name: string,
|
|
703
|
+
options?: { enabled?: boolean },
|
|
704
|
+
) {
|
|
705
|
+
const clusterInfo = useClusterInfo()
|
|
706
|
+
return useQuery<OpenCostWorkloadDetailResponse>({
|
|
707
|
+
queryKey: ['opencost-workload', kind, namespace, name],
|
|
708
|
+
queryFn: () =>
|
|
709
|
+
fetchJSON(
|
|
710
|
+
`/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`,
|
|
711
|
+
),
|
|
712
|
+
enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
|
|
602
713
|
staleTime: 30000,
|
|
714
|
+
refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
603
715
|
})
|
|
604
716
|
}
|
|
605
717
|
|
|
@@ -624,18 +736,175 @@ export interface OpenCostTrendResponse {
|
|
|
624
736
|
}
|
|
625
737
|
|
|
626
738
|
export function useOpenCostTrend(range_: CostTimeRange = '24h') {
|
|
739
|
+
const clusterInfo = useClusterInfo()
|
|
627
740
|
return useQuery<OpenCostTrendResponse>({
|
|
628
741
|
queryKey: ['opencost-trend', range_],
|
|
629
742
|
queryFn: () => fetchJSON(`/opencost/trend?range=${range_}`),
|
|
630
743
|
staleTime: 60000,
|
|
631
|
-
refetchInterval:
|
|
744
|
+
refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
632
745
|
placeholderData: (prev) => prev,
|
|
633
746
|
})
|
|
634
747
|
}
|
|
635
748
|
|
|
749
|
+
export interface OpenCostWorkloadTrendResponse {
|
|
750
|
+
available: boolean
|
|
751
|
+
reason?: CostUnavailableReason
|
|
752
|
+
namespace: string
|
|
753
|
+
kind: string
|
|
754
|
+
name: string
|
|
755
|
+
range: string
|
|
756
|
+
windowTotalCost?: number
|
|
757
|
+
dataPoints?: OpenCostTrendDataPoint[]
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export function useOpenCostWorkloadTrend(
|
|
761
|
+
kind: string,
|
|
762
|
+
namespace: string,
|
|
763
|
+
name: string,
|
|
764
|
+
range_: CostTimeRange = '24h',
|
|
765
|
+
options?: { enabled?: boolean },
|
|
766
|
+
) {
|
|
767
|
+
const clusterInfo = useClusterInfo()
|
|
768
|
+
return useQuery<OpenCostWorkloadTrendResponse>({
|
|
769
|
+
queryKey: ['opencost-workload-trend', kind, namespace, name, range_],
|
|
770
|
+
queryFn: () =>
|
|
771
|
+
fetchJSON(
|
|
772
|
+
`/opencost/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/trend?range=${range_}`,
|
|
773
|
+
),
|
|
774
|
+
enabled: (options?.enabled ?? true) && Boolean(kind && namespace && name),
|
|
775
|
+
staleTime: 60000,
|
|
776
|
+
refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export interface OpenCostApplicationWorkloadRef {
|
|
781
|
+
kind: string
|
|
782
|
+
namespace: string
|
|
783
|
+
name: string
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export interface OpenCostApplicationWorkloadStatus extends OpenCostApplicationWorkloadRef {
|
|
787
|
+
reason: CostUnavailableReason
|
|
788
|
+
scaledToZero?: boolean
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export interface OpenCostApplicationCostCoverage {
|
|
792
|
+
total: number
|
|
793
|
+
included: number
|
|
794
|
+
unavailable?: OpenCostApplicationWorkloadStatus[]
|
|
795
|
+
unsupported?: OpenCostApplicationWorkloadRef[]
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export interface OpenCostApplicationCostTotals {
|
|
799
|
+
hourlyCost: number
|
|
800
|
+
cpuCost: number
|
|
801
|
+
memoryCost: number
|
|
802
|
+
replicas: number
|
|
803
|
+
cpuUsageCost?: number
|
|
804
|
+
memoryUsageCost?: number
|
|
805
|
+
cpuUsageAvailable: boolean
|
|
806
|
+
memoryUsageAvailable: boolean
|
|
807
|
+
cpuAllocationUse: number
|
|
808
|
+
memoryAllocationUse: number
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export interface OpenCostApplicationWorkloadCost extends OpenCostApplicationWorkloadRef {
|
|
812
|
+
available: boolean
|
|
813
|
+
reason?: CostUnavailableReason
|
|
814
|
+
scaledToZero?: boolean
|
|
815
|
+
current?: OpenCostWorkloadCost
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export interface OpenCostApplicationCostResponse {
|
|
819
|
+
available: boolean
|
|
820
|
+
reason?: CostUnavailableReason
|
|
821
|
+
partial?: boolean
|
|
822
|
+
totals: OpenCostApplicationCostTotals
|
|
823
|
+
coverage: OpenCostApplicationCostCoverage
|
|
824
|
+
workloads?: OpenCostApplicationWorkloadCost[]
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export interface OpenCostApplicationCostTrendSeries extends OpenCostApplicationWorkloadRef {
|
|
828
|
+
windowTotalCost?: number
|
|
829
|
+
dataPoints?: OpenCostTrendDataPoint[]
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export interface OpenCostApplicationCostTrendResponse {
|
|
833
|
+
available: boolean
|
|
834
|
+
reason?: CostUnavailableReason
|
|
835
|
+
range: string
|
|
836
|
+
partial?: boolean
|
|
837
|
+
windowTotalCost?: number
|
|
838
|
+
dataPoints?: OpenCostTrendDataPoint[]
|
|
839
|
+
series?: OpenCostApplicationCostTrendSeries[]
|
|
840
|
+
coverage: OpenCostApplicationCostCoverage
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function stableOpenCostWorkloadRefs(
|
|
844
|
+
workloads: OpenCostApplicationWorkloadRef[],
|
|
845
|
+
): OpenCostApplicationWorkloadRef[] {
|
|
846
|
+
const byKey = new Map<string, OpenCostApplicationWorkloadRef>()
|
|
847
|
+
for (const workload of workloads) {
|
|
848
|
+
if (!workload.kind || !workload.namespace || !workload.name) continue
|
|
849
|
+
const ref = {
|
|
850
|
+
kind: workload.kind,
|
|
851
|
+
namespace: workload.namespace,
|
|
852
|
+
name: workload.name,
|
|
853
|
+
}
|
|
854
|
+
byKey.set(`${ref.namespace}/${ref.kind}/${ref.name}`, ref)
|
|
855
|
+
}
|
|
856
|
+
return [...byKey.values()].sort((a, b) =>
|
|
857
|
+
`${a.namespace}/${a.kind}/${a.name}`.localeCompare(`${b.namespace}/${b.kind}/${b.name}`),
|
|
858
|
+
)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export function useOpenCostApplicationCost(
|
|
862
|
+
workloads: OpenCostApplicationWorkloadRef[],
|
|
863
|
+
options?: { enabled?: boolean },
|
|
864
|
+
) {
|
|
865
|
+
const clusterInfo = useClusterInfo()
|
|
866
|
+
const refs = stableOpenCostWorkloadRefs(workloads)
|
|
867
|
+
return useQuery<OpenCostApplicationCostResponse>({
|
|
868
|
+
queryKey: ['opencost-application', refs],
|
|
869
|
+
queryFn: ({ signal }) =>
|
|
870
|
+
fetchJSON('/opencost/application', {
|
|
871
|
+
method: 'POST',
|
|
872
|
+
headers: { 'Content-Type': 'application/json' },
|
|
873
|
+
body: JSON.stringify({ workloads: refs }),
|
|
874
|
+
signal,
|
|
875
|
+
}),
|
|
876
|
+
enabled: (options?.enabled ?? true) && refs.length > 0,
|
|
877
|
+
staleTime: 30000,
|
|
878
|
+
refetchInterval: costRefetchInterval(COST_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
879
|
+
})
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
export function useOpenCostApplicationCostTrend(
|
|
883
|
+
workloads: OpenCostApplicationWorkloadRef[],
|
|
884
|
+
range_: CostTimeRange = '24h',
|
|
885
|
+
options?: { enabled?: boolean },
|
|
886
|
+
) {
|
|
887
|
+
const clusterInfo = useClusterInfo()
|
|
888
|
+
const refs = stableOpenCostWorkloadRefs(workloads)
|
|
889
|
+
return useQuery<OpenCostApplicationCostTrendResponse>({
|
|
890
|
+
queryKey: ['opencost-application-trend', refs, range_],
|
|
891
|
+
queryFn: ({ signal }) =>
|
|
892
|
+
fetchJSON('/opencost/application/trend', {
|
|
893
|
+
method: 'POST',
|
|
894
|
+
headers: { 'Content-Type': 'application/json' },
|
|
895
|
+
body: JSON.stringify({ workloads: refs, range: range_ }),
|
|
896
|
+
signal,
|
|
897
|
+
}),
|
|
898
|
+
enabled: (options?.enabled ?? true) && refs.length > 0,
|
|
899
|
+
staleTime: 60000,
|
|
900
|
+
refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
901
|
+
})
|
|
902
|
+
}
|
|
903
|
+
|
|
636
904
|
// Node cost breakdown
|
|
637
905
|
export interface OpenCostNodeCost {
|
|
638
906
|
name: string
|
|
907
|
+
providerID?: string
|
|
639
908
|
instanceType?: string
|
|
640
909
|
region?: string
|
|
641
910
|
hourlyCost: number
|
|
@@ -650,11 +919,12 @@ export interface OpenCostNodeResponse {
|
|
|
650
919
|
}
|
|
651
920
|
|
|
652
921
|
export function useOpenCostNodes() {
|
|
922
|
+
const clusterInfo = useClusterInfo()
|
|
653
923
|
return useQuery<OpenCostNodeResponse>({
|
|
654
924
|
queryKey: ['opencost-nodes'],
|
|
655
925
|
queryFn: () => fetchJSON('/opencost/nodes'),
|
|
656
926
|
staleTime: 60000,
|
|
657
|
-
refetchInterval:
|
|
927
|
+
refetchInterval: costRefetchInterval(COST_TREND_REFRESH_INTERVAL_MS, clusterInfo.data?.context),
|
|
658
928
|
placeholderData: (prev) => prev,
|
|
659
929
|
})
|
|
660
930
|
}
|
|
@@ -815,7 +1085,15 @@ const SEARCH_MIN_QUERY = 2
|
|
|
815
1085
|
// health/issueCount per hit (rich rows). React Query's AbortSignal cancels
|
|
816
1086
|
// overlapping scans on a new query. keepPreviousData avoids flicker while the
|
|
817
1087
|
// next query resolves.
|
|
818
|
-
export function useSearch(
|
|
1088
|
+
export function useSearch(
|
|
1089
|
+
query: string,
|
|
1090
|
+
opts?: {
|
|
1091
|
+
limit?: number
|
|
1092
|
+
context?: 'summary' | 'none'
|
|
1093
|
+
enabled?: boolean
|
|
1094
|
+
globalNs?: boolean
|
|
1095
|
+
},
|
|
1096
|
+
) {
|
|
819
1097
|
const trimmed = query.trim()
|
|
820
1098
|
const enabled = (opts?.enabled ?? true) && trimmed.length >= SEARCH_MIN_QUERY
|
|
821
1099
|
const limit = opts?.limit ?? 20
|
|
@@ -827,7 +1105,10 @@ export function useSearch(query: string, opts?: { limit?: number; context?: 'sum
|
|
|
827
1105
|
return useQuery<SearchResult>({
|
|
828
1106
|
queryKey: ['search', trimmed, limit, context, globalNs],
|
|
829
1107
|
queryFn: ({ signal }) =>
|
|
830
|
-
fetchJSON<SearchResult>(
|
|
1108
|
+
fetchJSON<SearchResult>(
|
|
1109
|
+
`/search?q=${encodeURIComponent(trimmed)}&limit=${limit}&include=none&context=${context}${globalNs ? '&globalNs=1' : ''}`,
|
|
1110
|
+
signal,
|
|
1111
|
+
),
|
|
831
1112
|
enabled,
|
|
832
1113
|
staleTime: 2000,
|
|
833
1114
|
placeholderData: (prev) => prev, // keepPreviousData
|
|
@@ -862,7 +1143,10 @@ export function useCapabilities() {
|
|
|
862
1143
|
|
|
863
1144
|
// Namespace-scoped capabilities. Users with namespace-scoped RoleBindings may
|
|
864
1145
|
// have these permissions in specific namespaces.
|
|
865
|
-
export function useNamespaceCapabilities(
|
|
1146
|
+
export function useNamespaceCapabilities(
|
|
1147
|
+
namespace: string | undefined,
|
|
1148
|
+
globalCaps: Capabilities | undefined,
|
|
1149
|
+
) {
|
|
866
1150
|
const needsCheck = namespace && globalCaps
|
|
867
1151
|
return useQuery<Capabilities>({
|
|
868
1152
|
queryKey: ['capabilities', namespace],
|
|
@@ -901,7 +1185,11 @@ export function useAuthMe() {
|
|
|
901
1185
|
// CloudRole.AtLeast — the frontend must agree with the backend on what
|
|
902
1186
|
// "member-or-higher" means; otherwise we'd hide a button the
|
|
903
1187
|
// backend would happily honor (or vice versa).
|
|
904
|
-
const CLOUD_ROLE_RANK: Record<string, number> = {
|
|
1188
|
+
const CLOUD_ROLE_RANK: Record<string, number> = {
|
|
1189
|
+
viewer: 1,
|
|
1190
|
+
member: 2,
|
|
1191
|
+
owner: 3,
|
|
1192
|
+
}
|
|
905
1193
|
|
|
906
1194
|
/**
|
|
907
1195
|
* useCloudRole returns the caller's Cloud tier (`owner` / `member` /
|
|
@@ -982,34 +1270,74 @@ export function useNamespaces() {
|
|
|
982
1270
|
}
|
|
983
1271
|
|
|
984
1272
|
// Topology (for manual refresh)
|
|
985
|
-
export function useTopology(
|
|
1273
|
+
export function useTopology(
|
|
1274
|
+
namespaces: string[],
|
|
1275
|
+
viewMode: string = 'resources',
|
|
1276
|
+
options?: {
|
|
1277
|
+
enabled?: boolean
|
|
1278
|
+
includeReplicaSets?: boolean
|
|
1279
|
+
refetchInterval?: number | false
|
|
1280
|
+
},
|
|
1281
|
+
) {
|
|
986
1282
|
const params = new URLSearchParams()
|
|
987
1283
|
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
988
1284
|
if (viewMode) params.set('view', viewMode)
|
|
1285
|
+
if (options?.includeReplicaSets) params.set('includeReplicaSets', 'true')
|
|
989
1286
|
const queryString = params.toString()
|
|
990
1287
|
|
|
991
1288
|
return useQuery<Topology>({
|
|
992
|
-
queryKey: ['topology', namespaces, viewMode],
|
|
1289
|
+
queryKey: ['topology', namespaces, viewMode, options?.includeReplicaSets ?? false],
|
|
993
1290
|
queryFn: () => fetchJSON(`/topology${queryString ? `?${queryString}` : ''}`),
|
|
994
1291
|
staleTime: 5000, // 5 seconds
|
|
995
1292
|
enabled: options?.enabled !== false,
|
|
1293
|
+
refetchInterval: options?.refetchInterval,
|
|
996
1294
|
})
|
|
997
1295
|
}
|
|
998
1296
|
|
|
999
|
-
export function useApplications(namespaces: string[]) {
|
|
1297
|
+
export function useApplications(namespaces: string[], options?: { enabled?: boolean }) {
|
|
1000
1298
|
const params = new URLSearchParams()
|
|
1001
1299
|
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
1002
1300
|
const queryString = params.toString()
|
|
1003
1301
|
|
|
1302
|
+
const enabled = options?.enabled !== false
|
|
1004
1303
|
return useQuery<{ applications: AppRow[] }>({
|
|
1005
1304
|
queryKey: ['applications', namespaces],
|
|
1006
1305
|
queryFn: () => fetchJSON(`/applications${queryString ? `?${queryString}` : ''}`),
|
|
1007
1306
|
staleTime: 30_000,
|
|
1307
|
+
// Only poll while a consumer needs the index; gated off it must not keep the
|
|
1308
|
+
// background refetch alive.
|
|
1309
|
+
enabled,
|
|
1310
|
+
refetchInterval: enabled ? APPLICATIONS_REFRESH_INTERVAL_MS : false,
|
|
1311
|
+
})
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
export function useApplicationHistory(
|
|
1315
|
+
appKey: string | undefined,
|
|
1316
|
+
namespaces: string[],
|
|
1317
|
+
options?: { enabled?: boolean },
|
|
1318
|
+
) {
|
|
1319
|
+
const params = new URLSearchParams()
|
|
1320
|
+
if (appKey) params.set('app', appKey)
|
|
1321
|
+
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
1322
|
+
const queryString = params.toString()
|
|
1323
|
+
|
|
1324
|
+
return useQuery<AppHistory>({
|
|
1325
|
+
queryKey: ['application-history', appKey, namespaces],
|
|
1326
|
+
queryFn: appKey ? () => fetchJSON(`/applications/history?${queryString}`) : skipToken,
|
|
1327
|
+
enabled: Boolean(appKey) && (options?.enabled ?? true),
|
|
1328
|
+
staleTime: 15_000,
|
|
1008
1329
|
refetchInterval: APPLICATIONS_REFRESH_INTERVAL_MS,
|
|
1009
1330
|
})
|
|
1010
1331
|
}
|
|
1011
1332
|
|
|
1012
|
-
export function useGitOpsTree(
|
|
1333
|
+
export function useGitOpsTree(
|
|
1334
|
+
kind: string,
|
|
1335
|
+
namespace: string,
|
|
1336
|
+
name: string,
|
|
1337
|
+
group?: string,
|
|
1338
|
+
namespaces: string[] = [],
|
|
1339
|
+
options?: { enabled?: boolean },
|
|
1340
|
+
) {
|
|
1013
1341
|
const ns = namespace || '_'
|
|
1014
1342
|
const params = new URLSearchParams()
|
|
1015
1343
|
if (group) params.set('group', group)
|
|
@@ -1018,19 +1346,26 @@ export function useGitOpsTree(kind: string, namespace: string, name: string, gro
|
|
|
1018
1346
|
|
|
1019
1347
|
return useQuery<GitOpsResourceTree>({
|
|
1020
1348
|
queryKey: ['gitops-tree', kind, namespace, name, group, namespaces],
|
|
1021
|
-
queryFn: () =>
|
|
1022
|
-
|
|
1349
|
+
queryFn: () =>
|
|
1350
|
+
fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
|
|
1351
|
+
enabled: Boolean(kind && name) && (options?.enabled ?? true),
|
|
1023
1352
|
staleTime: 5000,
|
|
1024
1353
|
})
|
|
1025
1354
|
}
|
|
1026
1355
|
|
|
1027
1356
|
// Poll fast (2s) while a sync/rollback is in flight so the user sees the
|
|
1028
1357
|
// outcome quickly; otherwise rely on staleTime + manual refetch. Argo flips
|
|
1029
|
-
// operationState.phase from
|
|
1358
|
+
// operationState.phase from Running/Terminating to a terminal phase, so this
|
|
1030
1359
|
// auto-quiesces on completion.
|
|
1031
1360
|
const INSIGHTS_RUNNING_POLL_MS = 2000
|
|
1032
1361
|
|
|
1033
|
-
export function useGitOpsInsights(
|
|
1362
|
+
export function useGitOpsInsights(
|
|
1363
|
+
kind: string,
|
|
1364
|
+
namespace: string,
|
|
1365
|
+
name: string,
|
|
1366
|
+
group?: string,
|
|
1367
|
+
namespaces: string[] = [],
|
|
1368
|
+
) {
|
|
1034
1369
|
const ns = namespace || '_'
|
|
1035
1370
|
const params = new URLSearchParams()
|
|
1036
1371
|
if (group) params.set('group', group)
|
|
@@ -1039,19 +1374,71 @@ export function useGitOpsInsights(kind: string, namespace: string, name: string,
|
|
|
1039
1374
|
|
|
1040
1375
|
return useQuery<GitOpsInsight>({
|
|
1041
1376
|
queryKey: ['gitops-insights', kind, namespace, name, group, namespaces],
|
|
1042
|
-
queryFn: () =>
|
|
1377
|
+
queryFn: () =>
|
|
1378
|
+
fetchJSON(`/gitops/insights/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
|
|
1043
1379
|
enabled: Boolean(kind && name),
|
|
1044
1380
|
staleTime: 5000,
|
|
1045
1381
|
refetchInterval: (query) => {
|
|
1046
1382
|
const phase = query.state.data?.summary?.operationPhase
|
|
1047
|
-
return phase === 'Running' ? INSIGHTS_RUNNING_POLL_MS : false
|
|
1383
|
+
return phase === 'Running' || phase === 'Terminating' ? INSIGHTS_RUNNING_POLL_MS : false
|
|
1048
1384
|
},
|
|
1049
1385
|
})
|
|
1050
1386
|
}
|
|
1051
1387
|
|
|
1388
|
+
// Full Git-rendered desired-vs-live diff for one Argo CD managed resource.
|
|
1389
|
+
// ns/name identify the Application; the ref identifies the managed resource.
|
|
1390
|
+
// Fetched on demand — the caller mounts this only when the user opens "Full
|
|
1391
|
+
// diff", so it's enabled whenever the ref is resolvable. Errors surface via
|
|
1392
|
+
// fetchJSON's ApiError (server {"error"} string as .message).
|
|
1393
|
+
export function useArgoResourceDiff(appNamespace: string, appName: string, ref: GitOpsInsightRef) {
|
|
1394
|
+
const ns = appNamespace || '_'
|
|
1395
|
+
const params = new URLSearchParams()
|
|
1396
|
+
if (ref.group) params.set('group', ref.group)
|
|
1397
|
+
params.set('kind', ref.kind)
|
|
1398
|
+
if (ref.namespace) params.set('resourceNamespace', ref.namespace)
|
|
1399
|
+
params.set('resourceName', ref.name)
|
|
1400
|
+
|
|
1401
|
+
return useQuery<GitOpsResourceDiff>({
|
|
1402
|
+
queryKey: ['argo-resource-diff', appNamespace, appName, ref.group, ref.kind, ref.namespace, ref.name],
|
|
1403
|
+
queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/resource-diff?${params.toString()}`),
|
|
1404
|
+
enabled: Boolean(appName && ref.kind && ref.name),
|
|
1405
|
+
staleTime: 15_000,
|
|
1406
|
+
})
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Git commit metadata for one deployed revision of an Argo CD Application.
|
|
1410
|
+
// Enabled only when a revision is known and the caller passes `enabled` (gated
|
|
1411
|
+
// on capabilities.revisionMetadataAvailable). Cached long — a resolved SHA's
|
|
1412
|
+
// metadata is effectively immutable.
|
|
1413
|
+
export function useArgoRevisionMetadata(
|
|
1414
|
+
appNamespace: string,
|
|
1415
|
+
appName: string,
|
|
1416
|
+
revision: string | undefined,
|
|
1417
|
+
opts?: { sourceIndex?: number; project?: string; enabled?: boolean },
|
|
1418
|
+
) {
|
|
1419
|
+
const ns = appNamespace || '_'
|
|
1420
|
+
const params = new URLSearchParams()
|
|
1421
|
+
if (revision) params.set('revision', revision)
|
|
1422
|
+
if (opts?.sourceIndex != null) params.set('sourceIndex', String(opts.sourceIndex))
|
|
1423
|
+
if (opts?.project) params.set('project', opts.project)
|
|
1424
|
+
|
|
1425
|
+
return useQuery<ArgoRevisionMetadata>({
|
|
1426
|
+
queryKey: ['argo-revision-metadata', appNamespace, appName, revision, opts?.sourceIndex, opts?.project],
|
|
1427
|
+
queryFn: () => fetchJSON(`/argo/applications/${ns}/${appName}/revision-metadata?${params.toString()}`),
|
|
1428
|
+
enabled: Boolean(appName && revision) && (opts?.enabled ?? true),
|
|
1429
|
+
staleTime: 5 * 60_000,
|
|
1430
|
+
})
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1052
1433
|
// Generic resource fetching - returns resource with relationships
|
|
1053
1434
|
// Uses '_' as placeholder for cluster-scoped resources (empty namespace)
|
|
1054
|
-
export function useResource<T>(
|
|
1435
|
+
export function useResource<T>(
|
|
1436
|
+
kind: string,
|
|
1437
|
+
namespace: string,
|
|
1438
|
+
name: string,
|
|
1439
|
+
group?: string,
|
|
1440
|
+
options?: { enabled?: boolean; refetchInterval?: number | false },
|
|
1441
|
+
) {
|
|
1055
1442
|
// For cluster-scoped resources, use '_' as namespace placeholder
|
|
1056
1443
|
const ns = namespace || '_'
|
|
1057
1444
|
const params = new URLSearchParams()
|
|
@@ -1060,8 +1447,10 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
|
|
|
1060
1447
|
|
|
1061
1448
|
const query = useQuery<ResourceWithRelationships<T>>({
|
|
1062
1449
|
queryKey: ['resource', kind, namespace, name, group],
|
|
1063
|
-
queryFn: () =>
|
|
1064
|
-
|
|
1450
|
+
queryFn: () =>
|
|
1451
|
+
fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
|
|
1452
|
+
enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
|
|
1453
|
+
refetchInterval: options?.refetchInterval,
|
|
1065
1454
|
})
|
|
1066
1455
|
|
|
1067
1456
|
// Extract resource and relationships from the response
|
|
@@ -1075,7 +1464,12 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
|
|
|
1075
1464
|
}
|
|
1076
1465
|
|
|
1077
1466
|
// Hook that returns full response with relationships explicitly
|
|
1078
|
-
export function useResourceWithRelationships<T>(
|
|
1467
|
+
export function useResourceWithRelationships<T>(
|
|
1468
|
+
kind: string,
|
|
1469
|
+
namespace: string,
|
|
1470
|
+
name: string,
|
|
1471
|
+
group?: string,
|
|
1472
|
+
) {
|
|
1079
1473
|
const ns = namespace || '_'
|
|
1080
1474
|
const params = new URLSearchParams()
|
|
1081
1475
|
if (group) params.set('group', group)
|
|
@@ -1083,7 +1477,8 @@ export function useResourceWithRelationships<T>(kind: string, namespace: string,
|
|
|
1083
1477
|
|
|
1084
1478
|
return useQuery<ResourceWithRelationships<T>>({
|
|
1085
1479
|
queryKey: ['resource', kind, namespace, name, group],
|
|
1086
|
-
queryFn: () =>
|
|
1480
|
+
queryFn: () =>
|
|
1481
|
+
fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
|
|
1087
1482
|
enabled: Boolean(kind && name),
|
|
1088
1483
|
})
|
|
1089
1484
|
}
|
|
@@ -1112,7 +1507,10 @@ export function useResources<T>(
|
|
|
1112
1507
|
// Timeline changes (unified view of changes + K8s events)
|
|
1113
1508
|
export interface UseChangesOptions {
|
|
1114
1509
|
namespaces?: string[]
|
|
1115
|
-
kind
|
|
1510
|
+
// Kind filter. The server narrows to a single kind (tighter result caps), so
|
|
1511
|
+
// exactly one selected kind is pushed server-side; a multi-kind selection
|
|
1512
|
+
// fetches unfiltered and is narrowed client-side by the caller.
|
|
1513
|
+
kinds?: string[]
|
|
1116
1514
|
timeRange?: TimeRange
|
|
1117
1515
|
filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
|
|
1118
1516
|
includeK8sEvents?: boolean
|
|
@@ -1120,6 +1518,133 @@ export interface UseChangesOptions {
|
|
|
1120
1518
|
includeDeleted?: boolean
|
|
1121
1519
|
limit?: number
|
|
1122
1520
|
enabled?: boolean
|
|
1521
|
+
// Cursor-aware refetches: after the first full load, refetches ask the
|
|
1522
|
+
// server only for events that arrived after the highest seq already cached
|
|
1523
|
+
// and merge them in, instead of re-pulling the whole ring. Intended for the
|
|
1524
|
+
// timeline's full-ring (10k) query, where every SSE nudge would otherwise
|
|
1525
|
+
// re-transfer megabytes for a handful of new events.
|
|
1526
|
+
deltaSync?: boolean
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// The store epoch guards delta cursors: a restarted store restarts seq
|
|
1530
|
+
// numbering, so an epoch change forces a full resync. A periodic full resync
|
|
1531
|
+
// also runs as anti-entropy for anything a dropped SSE connection or a
|
|
1532
|
+
// server-side eviction could leave behind in the cached copy.
|
|
1533
|
+
const FULL_RESYNC_MS = 5 * 60_000
|
|
1534
|
+
|
|
1535
|
+
export interface ChangesDeltaMeta {
|
|
1536
|
+
epoch: string
|
|
1537
|
+
lastFullMs: number
|
|
1538
|
+
// Highest seq observed in ANY response for this query — not just what
|
|
1539
|
+
// survived the cap. A delta event older than everything cached gets capped
|
|
1540
|
+
// out of the merge; deriving the cursor from cached rows alone would then
|
|
1541
|
+
// re-request that same event on every refetch until the next full resync.
|
|
1542
|
+
highWaterSeq: number
|
|
1543
|
+
}
|
|
1544
|
+
const changesDeltaMeta = new Map<string, ChangesDeltaMeta>()
|
|
1545
|
+
|
|
1546
|
+
// The since_seq cursor for the next refetch, or 0 for a full fetch. Delta
|
|
1547
|
+
// requires an epoch-stamped prior full load, a cached page to merge into, and
|
|
1548
|
+
// the anti-entropy full resync not being due.
|
|
1549
|
+
export function deltaFetchCursor(
|
|
1550
|
+
meta: ChangesDeltaMeta | undefined,
|
|
1551
|
+
cached: TimelineEvent[] | undefined,
|
|
1552
|
+
nowMs: number,
|
|
1553
|
+
): number {
|
|
1554
|
+
if (!meta?.epoch || !cached) return 0
|
|
1555
|
+
if (nowMs - meta.lastFullMs > FULL_RESYNC_MS) return 0
|
|
1556
|
+
return Math.max(meta.highWaterSeq, maxEventSeq(cached))
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
async function fetchChangesPage(
|
|
1560
|
+
path: string,
|
|
1561
|
+
signal?: AbortSignal,
|
|
1562
|
+
): Promise<{ events: TimelineEvent[]; epoch: string; maxSeq: number }> {
|
|
1563
|
+
const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined)
|
|
1564
|
+
if (!response.ok) {
|
|
1565
|
+
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1566
|
+
throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
|
|
1567
|
+
}
|
|
1568
|
+
const events = (await response.json()) as TimelineEvent[]
|
|
1569
|
+
// maxSeq is the page's frontier computed before the server's
|
|
1570
|
+
// cluster-scoped-RBAC filter — rows dropped THERE still advance the cursor.
|
|
1571
|
+
// (Rows dropped by content filters inside the store query do not; see the
|
|
1572
|
+
// known limitation on the server's handleChanges.)
|
|
1573
|
+
const maxSeq = Number(response.headers.get('X-Radar-Timeline-Max-Seq') ?? '0') || 0
|
|
1574
|
+
return {
|
|
1575
|
+
events,
|
|
1576
|
+
epoch: response.headers.get('X-Radar-Timeline-Epoch') ?? '',
|
|
1577
|
+
maxSeq,
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// Highest store-assigned arrival number in the cached page — the delta cursor.
|
|
1582
|
+
export function maxEventSeq(events: TimelineEvent[]): number {
|
|
1583
|
+
let max = 0
|
|
1584
|
+
for (const event of events) {
|
|
1585
|
+
if (event.seq && event.seq > max) max = event.seq
|
|
1586
|
+
}
|
|
1587
|
+
return max
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// Merge a delta page into the cached page: a delta row replaces its cached id
|
|
1591
|
+
// (a K8s Event count bump re-arrives under the same id), new ids are added,
|
|
1592
|
+
// order stays newest-first (arrival number breaks timestamp ties), and the
|
|
1593
|
+
// result is capped to the query's limit by dropping the oldest.
|
|
1594
|
+
export function mergeDeltaEvents(
|
|
1595
|
+
prev: TimelineEvent[],
|
|
1596
|
+
delta: TimelineEvent[],
|
|
1597
|
+
cap: number,
|
|
1598
|
+
): TimelineEvent[] {
|
|
1599
|
+
if (delta.length === 0) return prev
|
|
1600
|
+
const replaced = new Set(delta.map((event) => event.id))
|
|
1601
|
+
const merged = [...delta, ...prev.filter((event) => !replaced.has(event.id))]
|
|
1602
|
+
merged.sort((a, b) => {
|
|
1603
|
+
const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
1604
|
+
if (byTime !== 0) return byTime
|
|
1605
|
+
return (b.seq ?? 0) - (a.seq ?? 0)
|
|
1606
|
+
})
|
|
1607
|
+
return merged.length > cap ? merged.slice(0, cap) : merged
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Delta-sync orchestration for useChanges, extracted so the
|
|
1611
|
+
// full-fetch → delta-poll → epoch-mismatch-resync contract is exercisable
|
|
1612
|
+
// without a React render. State is passed in explicitly — the cached page and
|
|
1613
|
+
// the shared meta store — rather than closed over from module scope, so a
|
|
1614
|
+
// caller (and a test) drives it with fresh state each invocation.
|
|
1615
|
+
export async function runDeltaSyncFetch(deps: {
|
|
1616
|
+
path: string
|
|
1617
|
+
queryString: string
|
|
1618
|
+
limit: number
|
|
1619
|
+
metaKey: string
|
|
1620
|
+
cached: TimelineEvent[] | undefined
|
|
1621
|
+
metaStore: Map<string, ChangesDeltaMeta>
|
|
1622
|
+
now: number
|
|
1623
|
+
signal?: AbortSignal
|
|
1624
|
+
}): Promise<TimelineEvent[]> {
|
|
1625
|
+
const { path, queryString, limit, metaKey, cached, metaStore, now, signal } = deps
|
|
1626
|
+
const meta = metaStore.get(metaKey)
|
|
1627
|
+
const cursor = deltaFetchCursor(meta, cached, now)
|
|
1628
|
+
if (cursor > 0) {
|
|
1629
|
+
const delta = await fetchChangesPage(
|
|
1630
|
+
`${path}${queryString ? '&' : '?'}since_seq=${cursor}`,
|
|
1631
|
+
signal,
|
|
1632
|
+
)
|
|
1633
|
+
if (delta.epoch && delta.epoch === meta!.epoch) {
|
|
1634
|
+
meta!.highWaterSeq = Math.max(meta!.highWaterSeq, delta.maxSeq, maxEventSeq(delta.events))
|
|
1635
|
+
// Returning the cached reference on an empty delta skips re-renders.
|
|
1636
|
+
return delta.events.length ? mergeDeltaEvents(cached!, delta.events, limit) : cached!
|
|
1637
|
+
}
|
|
1638
|
+
// Epoch changed — the store restarted and seq numbering reset, so the
|
|
1639
|
+
// cursor is meaningless. Fall through to a full resync.
|
|
1640
|
+
}
|
|
1641
|
+
const full = await fetchChangesPage(path, signal)
|
|
1642
|
+
metaStore.set(metaKey, {
|
|
1643
|
+
epoch: full.epoch,
|
|
1644
|
+
lastFullMs: now,
|
|
1645
|
+
highWaterSeq: Math.max(full.maxSeq, maxEventSeq(full.events)),
|
|
1646
|
+
})
|
|
1647
|
+
return full.events
|
|
1123
1648
|
}
|
|
1124
1649
|
|
|
1125
1650
|
function getTimeRangeDate(range: TimeRange): Date | null {
|
|
@@ -1136,17 +1661,37 @@ function getTimeRangeDate(range: TimeRange): Date | null {
|
|
|
1136
1661
|
return new Date(now.getTime() - 6 * 60 * 60 * 1000)
|
|
1137
1662
|
case '24h':
|
|
1138
1663
|
return new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
|
1664
|
+
case '7d':
|
|
1665
|
+
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
|
1666
|
+
case '30d':
|
|
1667
|
+
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
|
1139
1668
|
default:
|
|
1140
1669
|
return null
|
|
1141
1670
|
}
|
|
1142
1671
|
}
|
|
1143
1672
|
|
|
1144
1673
|
export function useChanges(options: UseChangesOptions = {}) {
|
|
1145
|
-
const {
|
|
1674
|
+
const {
|
|
1675
|
+
namespaces = [],
|
|
1676
|
+
kinds,
|
|
1677
|
+
timeRange = '1h',
|
|
1678
|
+
filter = 'all',
|
|
1679
|
+
includeK8sEvents = true,
|
|
1680
|
+
includeManaged = false,
|
|
1681
|
+
includeDeleted = true,
|
|
1682
|
+
limit = 200,
|
|
1683
|
+
enabled = true,
|
|
1684
|
+
deltaSync = false,
|
|
1685
|
+
} = options
|
|
1686
|
+
const queryClient = useQueryClient()
|
|
1687
|
+
|
|
1688
|
+
// Only a single-kind selection narrows the server query; a multi-kind
|
|
1689
|
+
// selection is filtered client-side so the server cap isn't spent on one kind.
|
|
1690
|
+
const serverKind = kinds && kinds.length === 1 ? kinds[0] : undefined
|
|
1146
1691
|
|
|
1147
1692
|
const params = new URLSearchParams()
|
|
1148
1693
|
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
1149
|
-
if (
|
|
1694
|
+
if (serverKind) params.set('kind', serverKind)
|
|
1150
1695
|
if (filter) params.set('filter', filter)
|
|
1151
1696
|
if (!includeK8sEvents) params.set('include_k8s_events', 'false')
|
|
1152
1697
|
if (includeManaged) params.set('include_managed', 'true')
|
|
@@ -1159,18 +1704,50 @@ export function useChanges(options: UseChangesOptions = {}) {
|
|
|
1159
1704
|
}
|
|
1160
1705
|
|
|
1161
1706
|
const queryString = params.toString()
|
|
1707
|
+
const path = `/changes${queryString ? `?${queryString}` : ''}`
|
|
1708
|
+
const queryKey = [
|
|
1709
|
+
'changes',
|
|
1710
|
+
namespaces,
|
|
1711
|
+
serverKind,
|
|
1712
|
+
timeRange,
|
|
1713
|
+
filter,
|
|
1714
|
+
includeK8sEvents,
|
|
1715
|
+
includeManaged,
|
|
1716
|
+
includeDeleted,
|
|
1717
|
+
limit,
|
|
1718
|
+
]
|
|
1162
1719
|
|
|
1163
1720
|
return useQuery<TimelineEvent[]>({
|
|
1164
|
-
queryKey
|
|
1165
|
-
queryFn:
|
|
1721
|
+
queryKey,
|
|
1722
|
+
queryFn: async ({ signal }) => {
|
|
1723
|
+
if (!deltaSync) return fetchJSON(path, signal)
|
|
1724
|
+
|
|
1725
|
+
const metaKey = JSON.stringify(queryKey)
|
|
1726
|
+
const cached = queryClient.getQueryData<TimelineEvent[]>(queryKey)
|
|
1727
|
+
return runDeltaSyncFetch({
|
|
1728
|
+
path,
|
|
1729
|
+
queryString,
|
|
1730
|
+
limit,
|
|
1731
|
+
metaKey,
|
|
1732
|
+
cached,
|
|
1733
|
+
metaStore: changesDeltaMeta,
|
|
1734
|
+
now: Date.now(),
|
|
1735
|
+
signal,
|
|
1736
|
+
})
|
|
1737
|
+
},
|
|
1166
1738
|
staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
|
|
1167
|
-
refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE handles real-time updates; this is
|
|
1739
|
+
refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE-driven invalidation handles real-time updates; this is the no-SSE fallback
|
|
1168
1740
|
enabled,
|
|
1169
1741
|
})
|
|
1170
1742
|
}
|
|
1171
1743
|
|
|
1172
1744
|
// Children changes for a parent workload (e.g., ReplicaSets and Pods under a Deployment)
|
|
1173
|
-
export function useResourceChildren(
|
|
1745
|
+
export function useResourceChildren(
|
|
1746
|
+
kind: string,
|
|
1747
|
+
namespace: string,
|
|
1748
|
+
name: string,
|
|
1749
|
+
timeRange: TimeRange = '1h',
|
|
1750
|
+
) {
|
|
1174
1751
|
const sinceDate = getTimeRangeDate(timeRange)
|
|
1175
1752
|
const params = new URLSearchParams()
|
|
1176
1753
|
if (sinceDate) {
|
|
@@ -1199,7 +1776,11 @@ export interface ResourceEventsResult {
|
|
|
1199
1776
|
// K8s events and resource updates are fetched separately so a high-frequency
|
|
1200
1777
|
// informer update stream (e.g. a CrashLoop status field flapping every few
|
|
1201
1778
|
// seconds) can never starve out user-meaningful K8s events under a shared limit.
|
|
1202
|
-
export function useResourceEvents(
|
|
1779
|
+
export function useResourceEvents(
|
|
1780
|
+
kind: string,
|
|
1781
|
+
namespace: string,
|
|
1782
|
+
name: string,
|
|
1783
|
+
): ResourceEventsResult {
|
|
1203
1784
|
// The timeline store keys events by their K8s Kind (singular PascalCase, e.g. "Pod"),
|
|
1204
1785
|
// but callers pass the URL-form kind ("pods").
|
|
1205
1786
|
const singularKind = pluralToKind(kind)
|
|
@@ -1265,8 +1846,8 @@ export function useResourceEvents(kind: string, namespace: string, name: string)
|
|
|
1265
1846
|
export interface ContainerMetrics {
|
|
1266
1847
|
name: string
|
|
1267
1848
|
usage: {
|
|
1268
|
-
cpu: string
|
|
1269
|
-
memory: string
|
|
1849
|
+
cpu: string // e.g., "10m" (millicores)
|
|
1850
|
+
memory: string // e.g., "128Mi"
|
|
1270
1851
|
}
|
|
1271
1852
|
}
|
|
1272
1853
|
|
|
@@ -1341,8 +1922,8 @@ export function useNodeMetrics(nodeName: string, options?: { enabled?: boolean }
|
|
|
1341
1922
|
|
|
1342
1923
|
export interface MetricsDataPoint {
|
|
1343
1924
|
timestamp: string
|
|
1344
|
-
cpu: number
|
|
1345
|
-
memory: number
|
|
1925
|
+
cpu: number // CPU in nanocores
|
|
1926
|
+
memory: number // Memory in bytes
|
|
1346
1927
|
}
|
|
1347
1928
|
|
|
1348
1929
|
export interface ContainerMetricsHistory {
|
|
@@ -1371,7 +1952,9 @@ export interface NodeMetricsHistory {
|
|
|
1371
1952
|
metricsUnavailableReason?: string
|
|
1372
1953
|
}
|
|
1373
1954
|
|
|
1374
|
-
function withoutCollectionError<
|
|
1955
|
+
function withoutCollectionError<
|
|
1956
|
+
T extends { collectionError?: string; rawCollectionError?: string },
|
|
1957
|
+
>(history: T): T {
|
|
1375
1958
|
const next = { ...history }
|
|
1376
1959
|
delete next.collectionError
|
|
1377
1960
|
delete next.rawCollectionError
|
|
@@ -1380,15 +1963,26 @@ function withoutCollectionError<T extends { collectionError?: string; rawCollect
|
|
|
1380
1963
|
|
|
1381
1964
|
export function normalizePodMetricsHistory(history: PodMetricsHistory): PodMetricsHistory {
|
|
1382
1965
|
if (history.metricsUnavailable !== true) return history
|
|
1383
|
-
return {
|
|
1966
|
+
return {
|
|
1967
|
+
...withoutCollectionError(history),
|
|
1968
|
+
metricsUnavailable: true,
|
|
1969
|
+
metricsUnavailableReason: history.rawCollectionError || history.collectionError,
|
|
1970
|
+
}
|
|
1384
1971
|
}
|
|
1385
1972
|
|
|
1386
1973
|
export function normalizeNodeMetricsHistory(history: NodeMetricsHistory): NodeMetricsHistory {
|
|
1387
1974
|
if (history.metricsUnavailable !== true) return history
|
|
1388
|
-
return {
|
|
1975
|
+
return {
|
|
1976
|
+
...withoutCollectionError(history),
|
|
1977
|
+
metricsUnavailable: true,
|
|
1978
|
+
metricsUnavailableReason: history.rawCollectionError || history.collectionError,
|
|
1979
|
+
}
|
|
1389
1980
|
}
|
|
1390
1981
|
|
|
1391
|
-
export function shouldFetchLiveMetrics(
|
|
1982
|
+
export function shouldFetchLiveMetrics(
|
|
1983
|
+
historySettled: boolean,
|
|
1984
|
+
metricsUnavailable: boolean,
|
|
1985
|
+
): boolean {
|
|
1392
1986
|
return historySettled && !metricsUnavailable
|
|
1393
1987
|
}
|
|
1394
1988
|
|
|
@@ -1396,7 +1990,11 @@ export function isLiveMetricsUnavailable(liveMetricsEnabled: boolean, metrics: u
|
|
|
1396
1990
|
return liveMetricsEnabled && metrics === null
|
|
1397
1991
|
}
|
|
1398
1992
|
|
|
1399
|
-
export function getVisibleLiveMetrics<T>(
|
|
1993
|
+
export function getVisibleLiveMetrics<T>(
|
|
1994
|
+
liveMetricsEnabled: boolean,
|
|
1995
|
+
metricsUnavailable: boolean,
|
|
1996
|
+
metrics: T | null | undefined,
|
|
1997
|
+
): T | undefined {
|
|
1400
1998
|
if (!liveMetricsEnabled || metricsUnavailable) return undefined
|
|
1401
1999
|
return metrics ?? undefined
|
|
1402
2000
|
}
|
|
@@ -1405,7 +2003,10 @@ export function getVisibleLiveMetrics<T>(liveMetricsEnabled: boolean, metricsUna
|
|
|
1405
2003
|
export function usePodMetricsHistory(namespace: string, podName: string) {
|
|
1406
2004
|
return useQuery<PodMetricsHistory>({
|
|
1407
2005
|
queryKey: ['pod-metrics-history', namespace, podName],
|
|
1408
|
-
queryFn: async () =>
|
|
2006
|
+
queryFn: async () =>
|
|
2007
|
+
normalizePodMetricsHistory(
|
|
2008
|
+
await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`),
|
|
2009
|
+
),
|
|
1409
2010
|
enabled: Boolean(namespace && podName),
|
|
1410
2011
|
staleTime: 25000, // Slightly less than poll interval
|
|
1411
2012
|
refetchInterval: 30000, // Match the backend poll interval
|
|
@@ -1416,7 +2017,10 @@ export function usePodMetricsHistory(namespace: string, podName: string) {
|
|
|
1416
2017
|
export function useNodeMetricsHistory(nodeName: string) {
|
|
1417
2018
|
return useQuery<NodeMetricsHistory>({
|
|
1418
2019
|
queryKey: ['node-metrics-history', nodeName],
|
|
1419
|
-
queryFn: async () =>
|
|
2020
|
+
queryFn: async () =>
|
|
2021
|
+
normalizeNodeMetricsHistory(
|
|
2022
|
+
await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`),
|
|
2023
|
+
),
|
|
1420
2024
|
enabled: Boolean(nodeName),
|
|
1421
2025
|
staleTime: 25000,
|
|
1422
2026
|
refetchInterval: 30000,
|
|
@@ -1427,20 +2031,20 @@ export function useNodeMetricsHistory(nodeName: string) {
|
|
|
1427
2031
|
export interface TopPodMetrics {
|
|
1428
2032
|
namespace: string
|
|
1429
2033
|
name: string
|
|
1430
|
-
cpu: number
|
|
1431
|
-
memory: number
|
|
1432
|
-
cpuRequest: number
|
|
1433
|
-
cpuLimit: number
|
|
2034
|
+
cpu: number // nanocores (usage)
|
|
2035
|
+
memory: number // bytes (usage)
|
|
2036
|
+
cpuRequest: number // nanocores (sum across containers)
|
|
2037
|
+
cpuLimit: number // nanocores (sum across containers)
|
|
1434
2038
|
memoryRequest: number // bytes (sum across containers)
|
|
1435
|
-
memoryLimit: number
|
|
2039
|
+
memoryLimit: number // bytes (sum across containers)
|
|
1436
2040
|
}
|
|
1437
2041
|
|
|
1438
2042
|
export interface TopNodeMetrics {
|
|
1439
2043
|
name: string
|
|
1440
|
-
cpu: number
|
|
1441
|
-
memory: number
|
|
1442
|
-
podCount: number
|
|
1443
|
-
cpuAllocatable: number
|
|
2044
|
+
cpu: number // nanocores (usage)
|
|
2045
|
+
memory: number // bytes (usage)
|
|
2046
|
+
podCount: number // pods scheduled on this node
|
|
2047
|
+
cpuAllocatable: number // nanocores
|
|
1444
2048
|
memoryAllocatable: number // bytes
|
|
1445
2049
|
}
|
|
1446
2050
|
|
|
@@ -1516,11 +2120,13 @@ export interface PrometheusResourceMetrics {
|
|
|
1516
2120
|
range: string
|
|
1517
2121
|
result: PrometheusQueryResult
|
|
1518
2122
|
query?: string // PromQL query (included when result is empty, for diagnostics)
|
|
1519
|
-
hint?: string
|
|
2123
|
+
hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
|
|
1520
2124
|
}
|
|
1521
2125
|
|
|
1522
|
-
export type PrometheusMetricCategory =
|
|
1523
|
-
|
|
2126
|
+
export type PrometheusMetricCategory =
|
|
2127
|
+
'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
|
|
2128
|
+
export type PrometheusTimeRange =
|
|
2129
|
+
'10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
|
|
1524
2130
|
|
|
1525
2131
|
// PVC usage at a moment in time, derived from kubelet_volume_stats_*.
|
|
1526
2132
|
// HasData=false silently indicates the CSI driver doesn't report or Prom
|
|
@@ -1534,17 +2140,49 @@ export interface PrometheusPVCUsage {
|
|
|
1534
2140
|
hasData: boolean
|
|
1535
2141
|
}
|
|
1536
2142
|
|
|
1537
|
-
export type
|
|
2143
|
+
export type RightsizingFit =
|
|
2144
|
+
'balanced' | 'oversized' | 'under_requested' | 'missing_request' | 'insufficient_history'
|
|
2145
|
+
export type RightsizingConfidence = 'low' | 'medium' | 'high'
|
|
2146
|
+
export type RightsizingOwnerCoverage = 'ksm_history' | 'current_pods'
|
|
1538
2147
|
|
|
1539
2148
|
export interface RightsizingRow {
|
|
1540
2149
|
container: string
|
|
1541
2150
|
resource: 'cpu' | 'memory'
|
|
2151
|
+
fit: RightsizingFit
|
|
2152
|
+
confidence: RightsizingConfidence
|
|
1542
2153
|
currentRequest?: string
|
|
2154
|
+
currentRequestValue?: number
|
|
1543
2155
|
currentLimit?: string
|
|
1544
|
-
|
|
2156
|
+
currentLimitValue?: number
|
|
2157
|
+
observed?: {
|
|
2158
|
+
name: 'P95' | 'P99' | 'Max'
|
|
2159
|
+
value: number
|
|
2160
|
+
formatted: string
|
|
2161
|
+
}
|
|
2162
|
+
peak?: {
|
|
2163
|
+
name: 'P99'
|
|
2164
|
+
value: number
|
|
2165
|
+
formatted: string
|
|
2166
|
+
}
|
|
2167
|
+
calculatedRequest?: string
|
|
2168
|
+
calculatedRequestValue?: number
|
|
1545
2169
|
recommendedRequest?: string
|
|
1546
|
-
|
|
1547
|
-
|
|
2170
|
+
recommendedRequestValue?: number
|
|
2171
|
+
reductionLimited?: boolean
|
|
2172
|
+
bursty?: boolean
|
|
2173
|
+
recommendationReason?: string
|
|
2174
|
+
sampleCount: number
|
|
2175
|
+
expectedSamples: number
|
|
2176
|
+
coverage: number
|
|
2177
|
+
hpaManaged: boolean
|
|
2178
|
+
hpaEvidenceAvailable: boolean
|
|
2179
|
+
throttleAvailable?: boolean
|
|
2180
|
+
throttleRatio?: number
|
|
2181
|
+
currentPodOOM?: boolean
|
|
2182
|
+
windowOomEvidence?: boolean
|
|
2183
|
+
oomEvidenceAvailable: boolean
|
|
2184
|
+
limitConflict?: boolean
|
|
2185
|
+
queryError?: string
|
|
1548
2186
|
}
|
|
1549
2187
|
|
|
1550
2188
|
export interface PrometheusRightsizing {
|
|
@@ -1552,11 +2190,46 @@ export interface PrometheusRightsizing {
|
|
|
1552
2190
|
namespace: string
|
|
1553
2191
|
name: string
|
|
1554
2192
|
window: string
|
|
2193
|
+
source: 'radar'
|
|
2194
|
+
ownerCoverage: RightsizingOwnerCoverage
|
|
2195
|
+
scaledToZero: boolean
|
|
1555
2196
|
sampleAvailable: boolean
|
|
1556
2197
|
rows: RightsizingRow[]
|
|
1557
2198
|
reason?: string
|
|
1558
2199
|
}
|
|
1559
2200
|
|
|
2201
|
+
export type RightsizingScanState = 'complete' | 'partial' | 'unavailable'
|
|
2202
|
+
|
|
2203
|
+
export interface RightsizingScanWorkload {
|
|
2204
|
+
kind: string
|
|
2205
|
+
namespace: string
|
|
2206
|
+
name: string
|
|
2207
|
+
replicas: number
|
|
2208
|
+
scaledToZero: boolean
|
|
2209
|
+
rows: RightsizingRow[]
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
export interface RightsizingScanCoverage {
|
|
2213
|
+
workloadsDiscovered: number
|
|
2214
|
+
workloadsEvaluated: number
|
|
2215
|
+
workloadsWithData: number
|
|
2216
|
+
batches: number
|
|
2217
|
+
completedBatches: number
|
|
2218
|
+
restrictedKinds?: string[]
|
|
2219
|
+
unavailableKinds?: string[]
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
export interface RightsizingScanResponse {
|
|
2223
|
+
state: RightsizingScanState
|
|
2224
|
+
scannedAt: string
|
|
2225
|
+
window: string
|
|
2226
|
+
source: 'radar'
|
|
2227
|
+
coverage: RightsizingScanCoverage
|
|
2228
|
+
workloads: RightsizingScanWorkload[]
|
|
2229
|
+
warnings?: { code: string; message: string }[]
|
|
2230
|
+
reason?: string
|
|
2231
|
+
}
|
|
2232
|
+
|
|
1560
2233
|
// Check Prometheus availability
|
|
1561
2234
|
export function usePrometheusStatus() {
|
|
1562
2235
|
return useQuery<PrometheusStatus>({
|
|
@@ -1567,12 +2240,32 @@ export function usePrometheusStatus() {
|
|
|
1567
2240
|
})
|
|
1568
2241
|
}
|
|
1569
2242
|
|
|
2243
|
+
export interface ArgoStatus {
|
|
2244
|
+
// configured = a URL or token is set; connected = a probe has landed and the
|
|
2245
|
+
// client is live. The two differ right after a restart (configured, reconnecting).
|
|
2246
|
+
configured: boolean
|
|
2247
|
+
connected: boolean
|
|
2248
|
+
address?: string
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
export function useArgoStatus(enabled = true) {
|
|
2252
|
+
return useQuery<ArgoStatus>({
|
|
2253
|
+
queryKey: ['argocd-status'],
|
|
2254
|
+
queryFn: () => fetchJSON('/integrations/argocd/status'),
|
|
2255
|
+
enabled,
|
|
2256
|
+
staleTime: 30000,
|
|
2257
|
+
refetchInterval: 60000,
|
|
2258
|
+
})
|
|
2259
|
+
}
|
|
2260
|
+
|
|
1570
2261
|
// Connect to Prometheus (trigger discovery)
|
|
1571
2262
|
export function usePrometheusConnect() {
|
|
1572
2263
|
const queryClient = useQueryClient()
|
|
1573
2264
|
return useMutation({
|
|
1574
2265
|
mutationFn: async () => {
|
|
1575
|
-
const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, {
|
|
2266
|
+
const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, {
|
|
2267
|
+
method: 'POST',
|
|
2268
|
+
})
|
|
1576
2269
|
if (!resp.ok) {
|
|
1577
2270
|
const body = await resp.json().catch(() => ({ error: 'Unknown error' }))
|
|
1578
2271
|
throw new Error(body.error || `HTTP ${resp.status}`)
|
|
@@ -1633,7 +2326,9 @@ export function useAutoPromConnect(): void {
|
|
|
1633
2326
|
|
|
1634
2327
|
// Persist the "we've connected here before" signal once a connection lands.
|
|
1635
2328
|
if (status?.connected) {
|
|
1636
|
-
try {
|
|
2329
|
+
try {
|
|
2330
|
+
window.localStorage.setItem(promAutoConnectKey(context), '1')
|
|
2331
|
+
} catch {
|
|
1637
2332
|
// localStorage can throw in some restricted browser modes — fail open.
|
|
1638
2333
|
}
|
|
1639
2334
|
return
|
|
@@ -1641,7 +2336,9 @@ export function useAutoPromConnect(): void {
|
|
|
1641
2336
|
|
|
1642
2337
|
if (attemptedRef.current === context) return
|
|
1643
2338
|
let cached: string | null = null
|
|
1644
|
-
try {
|
|
2339
|
+
try {
|
|
2340
|
+
cached = window.localStorage.getItem(promAutoConnectKey(context))
|
|
2341
|
+
} catch {
|
|
1645
2342
|
// keep the null fallback
|
|
1646
2343
|
}
|
|
1647
2344
|
|
|
@@ -1653,16 +2350,20 @@ export function useAutoPromConnect(): void {
|
|
|
1653
2350
|
const timeout = window.setTimeout(() => {
|
|
1654
2351
|
// Direct apiFetch (not via the usePrometheusConnect mutation) so the
|
|
1655
2352
|
// meta-driven toast handler stays silent — the user didn't click anything.
|
|
1656
|
-
apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, {
|
|
1657
|
-
|
|
2353
|
+
apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, {
|
|
2354
|
+
method: 'POST',
|
|
2355
|
+
})
|
|
2356
|
+
.then(async (resp) => {
|
|
1658
2357
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
1659
|
-
const nextStatus = await resp.json() as PrometheusStatus
|
|
2358
|
+
const nextStatus = (await resp.json()) as PrometheusStatus
|
|
1660
2359
|
queryClient.setQueryData(['prometheus-status'], nextStatus)
|
|
1661
2360
|
if (!nextStatus.connected) throw new Error(nextStatus.error || 'Prometheus unavailable')
|
|
1662
2361
|
queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
|
|
1663
2362
|
})
|
|
1664
2363
|
.catch(() => {
|
|
1665
|
-
try {
|
|
2364
|
+
try {
|
|
2365
|
+
window.localStorage.removeItem(promAutoConnectKey(context))
|
|
2366
|
+
} catch {
|
|
1666
2367
|
// ignore — manual CTA will render once status refreshes
|
|
1667
2368
|
}
|
|
1668
2369
|
attemptedRef.current = null
|
|
@@ -1720,8 +2421,7 @@ export function usePrometheusClusterMetrics(
|
|
|
1720
2421
|
) {
|
|
1721
2422
|
return useQuery<PrometheusResourceMetrics>({
|
|
1722
2423
|
queryKey: ['prometheus-cluster-metrics', category, range],
|
|
1723
|
-
queryFn: () =>
|
|
1724
|
-
fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
|
|
2424
|
+
queryFn: () => fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
|
|
1725
2425
|
enabled,
|
|
1726
2426
|
staleTime: 30000,
|
|
1727
2427
|
refetchInterval: 60000,
|
|
@@ -1740,22 +2440,75 @@ export function usePrometheusPVCUsage(namespace: string, name: string, enabled =
|
|
|
1740
2440
|
}
|
|
1741
2441
|
|
|
1742
2442
|
// Fetch rightsizing recommendations for a workload (Deployment / StatefulSet / DaemonSet).
|
|
1743
|
-
export function usePrometheusRightsizing(
|
|
2443
|
+
export function usePrometheusRightsizing(
|
|
2444
|
+
kind: string,
|
|
2445
|
+
namespace: string,
|
|
2446
|
+
name: string,
|
|
2447
|
+
enabled = true,
|
|
2448
|
+
) {
|
|
1744
2449
|
return useQuery<PrometheusRightsizing>({
|
|
1745
2450
|
queryKey: ['prometheus-rightsizing', kind, namespace, name],
|
|
1746
2451
|
queryFn: () => fetchJSON(`/prometheus/rightsizing/${kind}/${namespace}/${name}`),
|
|
1747
2452
|
enabled: enabled && Boolean(kind && namespace && name),
|
|
1748
|
-
staleTime: 5 * 60 * 1000,
|
|
2453
|
+
staleTime: 5 * 60 * 1000,
|
|
1749
2454
|
refetchInterval: 10 * 60 * 1000,
|
|
1750
2455
|
})
|
|
1751
2456
|
}
|
|
1752
2457
|
|
|
2458
|
+
const RIGHTSIZING_SCAN_CACHE_TIME = 5 * 60 * 1000
|
|
2459
|
+
|
|
2460
|
+
export function getRightsizingScanCacheConfig(
|
|
2461
|
+
namespaces: string[],
|
|
2462
|
+
context = '',
|
|
2463
|
+
): {
|
|
2464
|
+
namespaceKey: string
|
|
2465
|
+
queryKey: readonly ['prometheus-rightsizing-scan', string, string]
|
|
2466
|
+
queryFn: typeof skipToken
|
|
2467
|
+
gcTime: number
|
|
2468
|
+
} {
|
|
2469
|
+
const namespaceKey = [...namespaces].sort().join(',')
|
|
2470
|
+
return {
|
|
2471
|
+
namespaceKey,
|
|
2472
|
+
queryKey: ['prometheus-rightsizing-scan', context, namespaceKey] as const,
|
|
2473
|
+
queryFn: skipToken,
|
|
2474
|
+
gcTime: RIGHTSIZING_SCAN_CACHE_TIME,
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// A fleet rightsizing scan is intentionally manual. It can query seven days of
|
|
2479
|
+
// Prometheus history for many containers, so navigation alone must never run it.
|
|
2480
|
+
export function useRightsizingScan(namespaces: string[], context = '') {
|
|
2481
|
+
const queryClient = useQueryClient()
|
|
2482
|
+
const { namespaceKey, ...snapshotOptions } = getRightsizingScanCacheConfig(namespaces, context)
|
|
2483
|
+
const scanScope = { namespaceKey, queryKey: snapshotOptions.queryKey }
|
|
2484
|
+
const snapshot = useQuery<RightsizingScanResponse>(snapshotOptions)
|
|
2485
|
+
const mutation = useMutation({
|
|
2486
|
+
mutationFn: async (startedScope: typeof scanScope) => {
|
|
2487
|
+
const params = new URLSearchParams()
|
|
2488
|
+
if (startedScope.namespaceKey) params.set('namespaces', startedScope.namespaceKey)
|
|
2489
|
+
const query = params.toString()
|
|
2490
|
+
return fetchJSON<RightsizingScanResponse>(
|
|
2491
|
+
`/prometheus/rightsizing/scan${query ? `?${query}` : ''}`,
|
|
2492
|
+
{
|
|
2493
|
+
method: 'POST',
|
|
2494
|
+
},
|
|
2495
|
+
)
|
|
2496
|
+
},
|
|
2497
|
+
onSuccess: (result, startedScope) => queryClient.setQueryData(startedScope.queryKey, result),
|
|
2498
|
+
})
|
|
2499
|
+
return {
|
|
2500
|
+
...mutation,
|
|
2501
|
+
data: snapshot.data,
|
|
2502
|
+
mutate: () => mutation.mutate(scanScope),
|
|
2503
|
+
mutateAsync: () => mutation.mutateAsync(scanScope),
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
1753
2507
|
// Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
|
|
1754
2508
|
export function usePromQLRange(query: string, range: PrometheusTimeRange = '1h', enabled = true) {
|
|
1755
2509
|
return useQuery<PrometheusQueryResult>({
|
|
1756
2510
|
queryKey: ['promql-range', query, range],
|
|
1757
|
-
queryFn: () =>
|
|
1758
|
-
fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
|
|
2511
|
+
queryFn: () => fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
|
|
1759
2512
|
enabled: enabled && Boolean(query),
|
|
1760
2513
|
staleTime: 30000,
|
|
1761
2514
|
refetchInterval: 60000,
|
|
@@ -1788,12 +2541,16 @@ export interface LogStreamEvent {
|
|
|
1788
2541
|
}
|
|
1789
2542
|
|
|
1790
2543
|
// Fetch pod logs (non-streaming)
|
|
1791
|
-
export function usePodLogs(
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
2544
|
+
export function usePodLogs(
|
|
2545
|
+
namespace: string,
|
|
2546
|
+
podName: string,
|
|
2547
|
+
options?: {
|
|
2548
|
+
container?: string
|
|
2549
|
+
tailLines?: number
|
|
2550
|
+
previous?: boolean
|
|
2551
|
+
sinceSeconds?: number
|
|
2552
|
+
},
|
|
2553
|
+
) {
|
|
1797
2554
|
const params = new URLSearchParams()
|
|
1798
2555
|
if (options?.container) params.set('container', options.container)
|
|
1799
2556
|
if (options?.tailLines) params.set('tailLines', String(options.tailLines))
|
|
@@ -1802,8 +2559,17 @@ export function usePodLogs(namespace: string, podName: string, options?: {
|
|
|
1802
2559
|
const queryString = params.toString()
|
|
1803
2560
|
|
|
1804
2561
|
return useQuery<LogsResponse>({
|
|
1805
|
-
queryKey: [
|
|
1806
|
-
|
|
2562
|
+
queryKey: [
|
|
2563
|
+
'pod-logs',
|
|
2564
|
+
namespace,
|
|
2565
|
+
podName,
|
|
2566
|
+
options?.container,
|
|
2567
|
+
options?.tailLines,
|
|
2568
|
+
options?.previous,
|
|
2569
|
+
options?.sinceSeconds,
|
|
2570
|
+
],
|
|
2571
|
+
queryFn: () =>
|
|
2572
|
+
fetchJSON(`/pods/${namespace}/${podName}/logs${queryString ? `?${queryString}` : ''}`),
|
|
1807
2573
|
enabled: Boolean(namespace && podName),
|
|
1808
2574
|
staleTime: 5000, // Allow refetch after 5 seconds
|
|
1809
2575
|
})
|
|
@@ -1818,7 +2584,7 @@ export function createLogStream(
|
|
|
1818
2584
|
tailLines?: number
|
|
1819
2585
|
previous?: boolean
|
|
1820
2586
|
sinceSeconds?: number
|
|
1821
|
-
}
|
|
2587
|
+
},
|
|
1822
2588
|
): EventSource {
|
|
1823
2589
|
const params = new URLSearchParams()
|
|
1824
2590
|
if (options?.container) params.set('container', options.container)
|
|
@@ -1827,9 +2593,12 @@ export function createLogStream(
|
|
|
1827
2593
|
if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
|
|
1828
2594
|
const queryString = params.toString()
|
|
1829
2595
|
|
|
1830
|
-
return new EventSource(
|
|
1831
|
-
|
|
1832
|
-
|
|
2596
|
+
return new EventSource(
|
|
2597
|
+
`${getApiBase()}/pods/${namespace}/${podName}/logs/stream${queryString ? `?${queryString}` : ''}`,
|
|
2598
|
+
{
|
|
2599
|
+
withCredentials: getCredentialsMode() === 'include',
|
|
2600
|
+
},
|
|
2601
|
+
)
|
|
1833
2602
|
}
|
|
1834
2603
|
|
|
1835
2604
|
// ============================================================================
|
|
@@ -1862,8 +2631,23 @@ export function useUpdateResource() {
|
|
|
1862
2631
|
const queryClient = useQueryClient()
|
|
1863
2632
|
|
|
1864
2633
|
return useMutation({
|
|
1865
|
-
mutationFn: async ({
|
|
1866
|
-
|
|
2634
|
+
mutationFn: async ({
|
|
2635
|
+
kind,
|
|
2636
|
+
namespace,
|
|
2637
|
+
name,
|
|
2638
|
+
yaml,
|
|
2639
|
+
force = true,
|
|
2640
|
+
}: {
|
|
2641
|
+
kind: string
|
|
2642
|
+
namespace: string
|
|
2643
|
+
name: string
|
|
2644
|
+
yaml: string
|
|
2645
|
+
force?: boolean
|
|
2646
|
+
}) => {
|
|
2647
|
+
const url = new URL(
|
|
2648
|
+
`${getApiBase()}/resources/${kind}/${namespace}/${name}`,
|
|
2649
|
+
window.location.origin,
|
|
2650
|
+
)
|
|
1867
2651
|
if (!force) {
|
|
1868
2652
|
url.searchParams.set('force', 'false')
|
|
1869
2653
|
}
|
|
@@ -1891,16 +2675,22 @@ export function useUpdateResource() {
|
|
|
1891
2675
|
// lagging cache — the change appears not to have taken effect.
|
|
1892
2676
|
if (updated && typeof updated === 'object' && updated.metadata) {
|
|
1893
2677
|
queryClient.setQueriesData(
|
|
1894
|
-
{
|
|
2678
|
+
{
|
|
2679
|
+
queryKey: ['resource', variables.kind, variables.namespace, variables.name],
|
|
2680
|
+
},
|
|
1895
2681
|
(old: any) =>
|
|
1896
2682
|
old && typeof old === 'object' && 'resource' in old
|
|
1897
2683
|
? { ...old, resource: updated }
|
|
1898
|
-
: { resource: updated }
|
|
2684
|
+
: { resource: updated },
|
|
1899
2685
|
)
|
|
1900
2686
|
} else {
|
|
1901
|
-
queryClient.invalidateQueries({
|
|
2687
|
+
queryClient.invalidateQueries({
|
|
2688
|
+
queryKey: ['resource', variables.kind, variables.namespace, variables.name],
|
|
2689
|
+
})
|
|
1902
2690
|
}
|
|
1903
|
-
queryClient.invalidateQueries({
|
|
2691
|
+
queryClient.invalidateQueries({
|
|
2692
|
+
queryKey: ['resources', variables.kind],
|
|
2693
|
+
})
|
|
1904
2694
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1905
2695
|
},
|
|
1906
2696
|
})
|
|
@@ -1909,13 +2699,24 @@ export function useUpdateResource() {
|
|
|
1909
2699
|
// Cascade delete preview — shows resources that will be garbage-collected
|
|
1910
2700
|
export interface CascadeDeletePreview {
|
|
1911
2701
|
root: { kind: string; namespace: string; name: string; group?: string }
|
|
1912
|
-
dependents: {
|
|
2702
|
+
dependents: {
|
|
2703
|
+
kind: string
|
|
2704
|
+
namespace: string
|
|
2705
|
+
name: string
|
|
2706
|
+
group?: string
|
|
2707
|
+
}[]
|
|
1913
2708
|
}
|
|
1914
2709
|
|
|
1915
|
-
export function useCascadeDeletePreview(
|
|
2710
|
+
export function useCascadeDeletePreview(
|
|
2711
|
+
kind: string,
|
|
2712
|
+
namespace: string,
|
|
2713
|
+
name: string,
|
|
2714
|
+
enabled: boolean,
|
|
2715
|
+
) {
|
|
1916
2716
|
return useQuery<CascadeDeletePreview>({
|
|
1917
2717
|
queryKey: ['cascade-preview', kind, namespace, name],
|
|
1918
|
-
queryFn: () =>
|
|
2718
|
+
queryFn: () =>
|
|
2719
|
+
fetchJSON<CascadeDeletePreview>(`/resources/${kind}/${namespace}/${name}/cascade-preview`),
|
|
1919
2720
|
enabled,
|
|
1920
2721
|
staleTime: 30_000,
|
|
1921
2722
|
})
|
|
@@ -1926,8 +2727,23 @@ export function useDeleteResource() {
|
|
|
1926
2727
|
const queryClient = useQueryClient()
|
|
1927
2728
|
|
|
1928
2729
|
return useMutation({
|
|
1929
|
-
mutationFn: async ({
|
|
1930
|
-
|
|
2730
|
+
mutationFn: async ({
|
|
2731
|
+
kind,
|
|
2732
|
+
group,
|
|
2733
|
+
namespace,
|
|
2734
|
+
name,
|
|
2735
|
+
force,
|
|
2736
|
+
}: {
|
|
2737
|
+
kind: string
|
|
2738
|
+
group?: string
|
|
2739
|
+
namespace: string
|
|
2740
|
+
name: string
|
|
2741
|
+
force?: boolean
|
|
2742
|
+
}) => {
|
|
2743
|
+
const url = new URL(
|
|
2744
|
+
`${getApiBase()}/resources/${kind}/${namespace}/${name}`,
|
|
2745
|
+
window.location.origin,
|
|
2746
|
+
)
|
|
1931
2747
|
if (group) {
|
|
1932
2748
|
url.searchParams.set('group', group)
|
|
1933
2749
|
}
|
|
@@ -1949,7 +2765,9 @@ export function useDeleteResource() {
|
|
|
1949
2765
|
successMessage: 'Resource deleted',
|
|
1950
2766
|
},
|
|
1951
2767
|
onSuccess: (_, variables) => {
|
|
1952
|
-
queryClient.invalidateQueries({
|
|
2768
|
+
queryClient.invalidateQueries({
|
|
2769
|
+
queryKey: ['resources', variables.kind],
|
|
2770
|
+
})
|
|
1953
2771
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1954
2772
|
},
|
|
1955
2773
|
})
|
|
@@ -1959,10 +2777,24 @@ export function useBulkDeleteResources() {
|
|
|
1959
2777
|
const queryClient = useQueryClient()
|
|
1960
2778
|
|
|
1961
2779
|
return useMutation({
|
|
1962
|
-
mutationFn: async ({
|
|
2780
|
+
mutationFn: async ({
|
|
2781
|
+
items,
|
|
2782
|
+
force,
|
|
2783
|
+
}: {
|
|
2784
|
+
items: Array<{
|
|
2785
|
+
kind: string
|
|
2786
|
+
group?: string
|
|
2787
|
+
namespace: string
|
|
2788
|
+
name: string
|
|
2789
|
+
}>
|
|
2790
|
+
force?: boolean
|
|
2791
|
+
}) => {
|
|
1963
2792
|
const results = await Promise.allSettled(
|
|
1964
2793
|
items.map(async ({ kind, group, namespace, name }) => {
|
|
1965
|
-
const url = new URL(
|
|
2794
|
+
const url = new URL(
|
|
2795
|
+
`${getApiBase()}/resources/${kind}/${namespace}/${name}`,
|
|
2796
|
+
window.location.origin,
|
|
2797
|
+
)
|
|
1966
2798
|
if (group) url.searchParams.set('group', group)
|
|
1967
2799
|
if (force) url.searchParams.set('force', 'true')
|
|
1968
2800
|
const response = await apiFetch(url.toString(), { method: 'DELETE' })
|
|
@@ -1971,9 +2803,9 @@ export function useBulkDeleteResources() {
|
|
|
1971
2803
|
throw new Error(error.error || `Failed to delete ${namespace}/${name}`)
|
|
1972
2804
|
}
|
|
1973
2805
|
return { kind, namespace, name }
|
|
1974
|
-
})
|
|
2806
|
+
}),
|
|
1975
2807
|
)
|
|
1976
|
-
const failed = results.filter(r => r.status === 'rejected')
|
|
2808
|
+
const failed = results.filter((r) => r.status === 'rejected')
|
|
1977
2809
|
if (failed.length > 0) {
|
|
1978
2810
|
throw new Error(`Failed to delete ${failed.length} of ${items.length} resources`)
|
|
1979
2811
|
}
|
|
@@ -2006,13 +2838,19 @@ interface BulkWorkloadMutationResult {
|
|
|
2006
2838
|
}
|
|
2007
2839
|
|
|
2008
2840
|
function failedBulkWorkloadMessages(results: PromiseSettledResult<unknown>[]): string[] {
|
|
2009
|
-
return results.flatMap(r =>
|
|
2010
|
-
|
|
2011
|
-
|
|
2841
|
+
return results.flatMap((r) =>
|
|
2842
|
+
r.status === 'rejected'
|
|
2843
|
+
? [r.reason instanceof Error ? r.reason.message : String(r.reason)]
|
|
2844
|
+
: [],
|
|
2012
2845
|
)
|
|
2013
2846
|
}
|
|
2014
2847
|
|
|
2015
|
-
function bulkWorkloadFailureMessage(
|
|
2848
|
+
function bulkWorkloadFailureMessage(
|
|
2849
|
+
action: string,
|
|
2850
|
+
failed: number,
|
|
2851
|
+
total: number,
|
|
2852
|
+
messages: string[],
|
|
2853
|
+
): string {
|
|
2016
2854
|
return `Failed to ${action} ${failed} of ${total} workloads:\n${messages.join('\n')}`
|
|
2017
2855
|
}
|
|
2018
2856
|
|
|
@@ -2020,27 +2858,45 @@ export function useBulkRestartWorkloads() {
|
|
|
2020
2858
|
const queryClient = useQueryClient()
|
|
2021
2859
|
|
|
2022
2860
|
return useMutation({
|
|
2023
|
-
mutationFn: async ({
|
|
2861
|
+
mutationFn: async ({
|
|
2862
|
+
items,
|
|
2863
|
+
}: {
|
|
2864
|
+
items: BulkWorkloadItem[]
|
|
2865
|
+
}): Promise<BulkWorkloadMutationResult> => {
|
|
2024
2866
|
if (items.length === 0) {
|
|
2025
2867
|
return { requested: 0, succeeded: 0, failedMessages: [] }
|
|
2026
2868
|
}
|
|
2027
2869
|
const results = await Promise.allSettled(
|
|
2028
2870
|
items.map(async ({ kind, namespace, name }) => {
|
|
2029
|
-
const response = await apiFetch(
|
|
2030
|
-
|
|
2031
|
-
|
|
2871
|
+
const response = await apiFetch(
|
|
2872
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
|
|
2873
|
+
{
|
|
2874
|
+
method: 'POST',
|
|
2875
|
+
},
|
|
2876
|
+
)
|
|
2032
2877
|
if (!response.ok) {
|
|
2033
2878
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2034
2879
|
throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
|
|
2035
2880
|
}
|
|
2036
2881
|
return { kind, namespace, name }
|
|
2037
|
-
})
|
|
2882
|
+
}),
|
|
2038
2883
|
)
|
|
2039
2884
|
const failedMessages = failedBulkWorkloadMessages(results)
|
|
2040
2885
|
if (failedMessages.length === items.length) {
|
|
2041
|
-
throw new Error(
|
|
2886
|
+
throw new Error(
|
|
2887
|
+
bulkWorkloadFailureMessage(
|
|
2888
|
+
'restart',
|
|
2889
|
+
failedMessages.length,
|
|
2890
|
+
items.length,
|
|
2891
|
+
failedMessages,
|
|
2892
|
+
),
|
|
2893
|
+
)
|
|
2894
|
+
}
|
|
2895
|
+
return {
|
|
2896
|
+
requested: items.length,
|
|
2897
|
+
succeeded: items.length - failedMessages.length,
|
|
2898
|
+
failedMessages,
|
|
2042
2899
|
}
|
|
2043
|
-
return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
|
|
2044
2900
|
},
|
|
2045
2901
|
meta: {
|
|
2046
2902
|
errorMessage: 'Failed to restart some workloads',
|
|
@@ -2066,29 +2922,44 @@ export function useBulkScaleWorkloads() {
|
|
|
2066
2922
|
const queryClient = useQueryClient()
|
|
2067
2923
|
|
|
2068
2924
|
return useMutation({
|
|
2069
|
-
mutationFn: async ({
|
|
2925
|
+
mutationFn: async ({
|
|
2926
|
+
items,
|
|
2927
|
+
replicas,
|
|
2928
|
+
}: {
|
|
2929
|
+
items: BulkWorkloadItem[]
|
|
2930
|
+
replicas: number
|
|
2931
|
+
}): Promise<BulkWorkloadMutationResult> => {
|
|
2070
2932
|
if (items.length === 0) {
|
|
2071
2933
|
return { requested: 0, succeeded: 0, failedMessages: [] }
|
|
2072
2934
|
}
|
|
2073
2935
|
const results = await Promise.allSettled(
|
|
2074
2936
|
items.map(async ({ kind, namespace, name }) => {
|
|
2075
|
-
const response = await apiFetch(
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2937
|
+
const response = await apiFetch(
|
|
2938
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
|
|
2939
|
+
{
|
|
2940
|
+
method: 'POST',
|
|
2941
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2942
|
+
body: JSON.stringify({ replicas }),
|
|
2943
|
+
},
|
|
2944
|
+
)
|
|
2080
2945
|
if (!response.ok) {
|
|
2081
2946
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2082
2947
|
throw new Error(`${namespace}/${name}: ${error.error || `HTTP ${response.status}`}`)
|
|
2083
2948
|
}
|
|
2084
2949
|
return { kind, namespace, name }
|
|
2085
|
-
})
|
|
2950
|
+
}),
|
|
2086
2951
|
)
|
|
2087
2952
|
const failedMessages = failedBulkWorkloadMessages(results)
|
|
2088
2953
|
if (failedMessages.length === items.length) {
|
|
2089
|
-
throw new Error(
|
|
2954
|
+
throw new Error(
|
|
2955
|
+
bulkWorkloadFailureMessage('scale', failedMessages.length, items.length, failedMessages),
|
|
2956
|
+
)
|
|
2957
|
+
}
|
|
2958
|
+
return {
|
|
2959
|
+
requested: items.length,
|
|
2960
|
+
succeeded: items.length - failedMessages.length,
|
|
2961
|
+
failedMessages,
|
|
2090
2962
|
}
|
|
2091
|
-
return { requested: items.length, succeeded: items.length - failedMessages.length, failedMessages }
|
|
2092
2963
|
},
|
|
2093
2964
|
meta: {
|
|
2094
2965
|
errorMessage: 'Failed to scale some workloads',
|
|
@@ -2122,7 +2993,17 @@ export function useApplyResource() {
|
|
|
2122
2993
|
const queryClient = useQueryClient()
|
|
2123
2994
|
|
|
2124
2995
|
return useMutation({
|
|
2125
|
-
mutationFn: async ({
|
|
2996
|
+
mutationFn: async ({
|
|
2997
|
+
yaml,
|
|
2998
|
+
mode = 'apply',
|
|
2999
|
+
dryRun = false,
|
|
3000
|
+
force = false,
|
|
3001
|
+
}: {
|
|
3002
|
+
yaml: string
|
|
3003
|
+
mode?: 'apply' | 'create'
|
|
3004
|
+
dryRun?: boolean
|
|
3005
|
+
force?: boolean
|
|
3006
|
+
}) => {
|
|
2126
3007
|
const url = new URL(`${getApiBase()}/resources/apply`, window.location.origin)
|
|
2127
3008
|
url.searchParams.set('mode', mode)
|
|
2128
3009
|
if (dryRun) {
|
|
@@ -2155,6 +3036,25 @@ export function useApplyResource() {
|
|
|
2155
3036
|
// CronJob operations
|
|
2156
3037
|
// ============================================================================
|
|
2157
3038
|
|
|
3039
|
+
function invalidateCronJobOperationQueries(
|
|
3040
|
+
queryClient: ReturnType<typeof useQueryClient>,
|
|
3041
|
+
namespace: string,
|
|
3042
|
+
name: string,
|
|
3043
|
+
) {
|
|
3044
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
|
|
3045
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
|
|
3046
|
+
queryClient.invalidateQueries({
|
|
3047
|
+
queryKey: ['resource', 'cronjobs', namespace, name],
|
|
3048
|
+
})
|
|
3049
|
+
queryClient.invalidateQueries({
|
|
3050
|
+
queryKey: ['workload-runs', 'cronjobs', namespace, name],
|
|
3051
|
+
})
|
|
3052
|
+
queryClient.invalidateQueries({ queryKey: ['applications'] })
|
|
3053
|
+
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
|
3054
|
+
queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
|
|
3055
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
3056
|
+
}
|
|
3057
|
+
|
|
2158
3058
|
// Trigger a CronJob (create a Job from it)
|
|
2159
3059
|
export function useTriggerCronJob() {
|
|
2160
3060
|
const queryClient = useQueryClient()
|
|
@@ -2174,10 +3074,8 @@ export function useTriggerCronJob() {
|
|
|
2174
3074
|
errorMessage: 'Failed to trigger CronJob',
|
|
2175
3075
|
successMessage: 'CronJob triggered',
|
|
2176
3076
|
},
|
|
2177
|
-
onSuccess: () => {
|
|
2178
|
-
queryClient.
|
|
2179
|
-
queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
|
|
2180
|
-
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
3077
|
+
onSuccess: (_, variables) => {
|
|
3078
|
+
invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
|
|
2181
3079
|
},
|
|
2182
3080
|
})
|
|
2183
3081
|
}
|
|
@@ -2201,9 +3099,8 @@ export function useSuspendCronJob() {
|
|
|
2201
3099
|
errorMessage: 'Failed to suspend CronJob',
|
|
2202
3100
|
successMessage: 'CronJob suspended',
|
|
2203
3101
|
},
|
|
2204
|
-
onSuccess: () => {
|
|
2205
|
-
queryClient.
|
|
2206
|
-
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
3102
|
+
onSuccess: (_, variables) => {
|
|
3103
|
+
invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
|
|
2207
3104
|
},
|
|
2208
3105
|
})
|
|
2209
3106
|
}
|
|
@@ -2227,9 +3124,8 @@ export function useResumeCronJob() {
|
|
|
2227
3124
|
errorMessage: 'Failed to resume CronJob',
|
|
2228
3125
|
successMessage: 'CronJob resumed',
|
|
2229
3126
|
},
|
|
2230
|
-
onSuccess: () => {
|
|
2231
|
-
queryClient.
|
|
2232
|
-
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
3127
|
+
onSuccess: (_, variables) => {
|
|
3128
|
+
invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
|
|
2233
3129
|
},
|
|
2234
3130
|
})
|
|
2235
3131
|
}
|
|
@@ -2243,10 +3139,21 @@ export function useRestartWorkload() {
|
|
|
2243
3139
|
const queryClient = useQueryClient()
|
|
2244
3140
|
|
|
2245
3141
|
return useMutation({
|
|
2246
|
-
mutationFn: async ({
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
3142
|
+
mutationFn: async ({
|
|
3143
|
+
kind,
|
|
3144
|
+
namespace,
|
|
3145
|
+
name,
|
|
3146
|
+
}: {
|
|
3147
|
+
kind: string
|
|
3148
|
+
namespace: string
|
|
3149
|
+
name: string
|
|
3150
|
+
}) => {
|
|
3151
|
+
const response = await apiFetch(
|
|
3152
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`,
|
|
3153
|
+
{
|
|
3154
|
+
method: 'POST',
|
|
3155
|
+
},
|
|
3156
|
+
)
|
|
2250
3157
|
if (!response.ok) {
|
|
2251
3158
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2252
3159
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -2258,7 +3165,9 @@ export function useRestartWorkload() {
|
|
|
2258
3165
|
successMessage: 'Workload restarting',
|
|
2259
3166
|
},
|
|
2260
3167
|
onSuccess: (_, variables) => {
|
|
2261
|
-
queryClient.invalidateQueries({
|
|
3168
|
+
queryClient.invalidateQueries({
|
|
3169
|
+
queryKey: ['resources', variables.kind],
|
|
3170
|
+
})
|
|
2262
3171
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
2263
3172
|
},
|
|
2264
3173
|
})
|
|
@@ -2269,12 +3178,25 @@ export function useScaleWorkload() {
|
|
|
2269
3178
|
const queryClient = useQueryClient()
|
|
2270
3179
|
|
|
2271
3180
|
return useMutation({
|
|
2272
|
-
mutationFn: async ({
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
3181
|
+
mutationFn: async ({
|
|
3182
|
+
kind,
|
|
3183
|
+
namespace,
|
|
3184
|
+
name,
|
|
3185
|
+
replicas,
|
|
3186
|
+
}: {
|
|
3187
|
+
kind: string
|
|
3188
|
+
namespace: string
|
|
3189
|
+
name: string
|
|
3190
|
+
replicas: number
|
|
3191
|
+
}) => {
|
|
3192
|
+
const response = await apiFetch(
|
|
3193
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`,
|
|
3194
|
+
{
|
|
3195
|
+
method: 'POST',
|
|
3196
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3197
|
+
body: JSON.stringify({ replicas }),
|
|
3198
|
+
},
|
|
3199
|
+
)
|
|
2278
3200
|
if (!response.ok) {
|
|
2279
3201
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2280
3202
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -2286,8 +3208,12 @@ export function useScaleWorkload() {
|
|
|
2286
3208
|
successMessage: 'Workload scaled',
|
|
2287
3209
|
},
|
|
2288
3210
|
onSuccess: (_, variables) => {
|
|
2289
|
-
queryClient.invalidateQueries({
|
|
2290
|
-
|
|
3211
|
+
queryClient.invalidateQueries({
|
|
3212
|
+
queryKey: ['resources', variables.kind],
|
|
3213
|
+
})
|
|
3214
|
+
queryClient.invalidateQueries({
|
|
3215
|
+
queryKey: ['resource', variables.kind, variables.namespace, variables.name],
|
|
3216
|
+
})
|
|
2291
3217
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
2292
3218
|
},
|
|
2293
3219
|
})
|
|
@@ -2307,7 +3233,12 @@ export interface WorkloadRevision {
|
|
|
2307
3233
|
template?: string // Pod template spec as YAML (for revision diff)
|
|
2308
3234
|
}
|
|
2309
3235
|
|
|
2310
|
-
export function useWorkloadRevisions(
|
|
3236
|
+
export function useWorkloadRevisions(
|
|
3237
|
+
kind: string,
|
|
3238
|
+
namespace: string,
|
|
3239
|
+
name: string,
|
|
3240
|
+
enabled = true,
|
|
3241
|
+
) {
|
|
2311
3242
|
return useQuery<WorkloadRevision[]>({
|
|
2312
3243
|
queryKey: ['workload-revisions', kind, namespace, name],
|
|
2313
3244
|
queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/revisions`),
|
|
@@ -2318,12 +3249,25 @@ export function useWorkloadRevisions(kind: string, namespace: string, name: stri
|
|
|
2318
3249
|
export function useRollbackWorkload() {
|
|
2319
3250
|
const queryClient = useQueryClient()
|
|
2320
3251
|
return useMutation({
|
|
2321
|
-
mutationFn: async ({
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
3252
|
+
mutationFn: async ({
|
|
3253
|
+
kind,
|
|
3254
|
+
namespace,
|
|
3255
|
+
name,
|
|
3256
|
+
revision,
|
|
3257
|
+
}: {
|
|
3258
|
+
kind: string
|
|
3259
|
+
namespace: string
|
|
3260
|
+
name: string
|
|
3261
|
+
revision: number
|
|
3262
|
+
}) => {
|
|
3263
|
+
const response = await apiFetch(
|
|
3264
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/rollback`,
|
|
3265
|
+
{
|
|
3266
|
+
method: 'POST',
|
|
3267
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3268
|
+
body: JSON.stringify({ revision }),
|
|
3269
|
+
},
|
|
3270
|
+
)
|
|
2327
3271
|
if (!response.ok) {
|
|
2328
3272
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2329
3273
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -2335,9 +3279,15 @@ export function useRollbackWorkload() {
|
|
|
2335
3279
|
successMessage: 'Rollback initiated',
|
|
2336
3280
|
},
|
|
2337
3281
|
onSuccess: (_, variables) => {
|
|
2338
|
-
queryClient.invalidateQueries({
|
|
2339
|
-
|
|
2340
|
-
|
|
3282
|
+
queryClient.invalidateQueries({
|
|
3283
|
+
queryKey: ['resources', variables.kind],
|
|
3284
|
+
})
|
|
3285
|
+
queryClient.invalidateQueries({
|
|
3286
|
+
queryKey: ['resource', variables.kind, variables.namespace, variables.name],
|
|
3287
|
+
})
|
|
3288
|
+
queryClient.invalidateQueries({
|
|
3289
|
+
queryKey: ['workload-revisions', variables.kind, variables.namespace, variables.name],
|
|
3290
|
+
})
|
|
2341
3291
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
2342
3292
|
},
|
|
2343
3293
|
})
|
|
@@ -2367,7 +3317,9 @@ export function useCordonNode() {
|
|
|
2367
3317
|
},
|
|
2368
3318
|
onSuccess: (_, variables) => {
|
|
2369
3319
|
queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
|
|
2370
|
-
queryClient.invalidateQueries({
|
|
3320
|
+
queryClient.invalidateQueries({
|
|
3321
|
+
queryKey: ['resource', 'nodes', '', variables.name],
|
|
3322
|
+
})
|
|
2371
3323
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
2372
3324
|
},
|
|
2373
3325
|
})
|
|
@@ -2393,7 +3345,9 @@ export function useUncordonNode() {
|
|
|
2393
3345
|
},
|
|
2394
3346
|
onSuccess: (_, variables) => {
|
|
2395
3347
|
queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
|
|
2396
|
-
queryClient.invalidateQueries({
|
|
3348
|
+
queryClient.invalidateQueries({
|
|
3349
|
+
queryKey: ['resource', 'nodes', '', variables.name],
|
|
3350
|
+
})
|
|
2397
3351
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
2398
3352
|
},
|
|
2399
3353
|
})
|
|
@@ -2426,7 +3380,9 @@ export function useDrainNode() {
|
|
|
2426
3380
|
},
|
|
2427
3381
|
onSuccess: (data: { evictedPods?: string[]; errors?: string[] }, variables) => {
|
|
2428
3382
|
queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
|
|
2429
|
-
queryClient.invalidateQueries({
|
|
3383
|
+
queryClient.invalidateQueries({
|
|
3384
|
+
queryKey: ['resource', 'nodes', '', variables.name],
|
|
3385
|
+
})
|
|
2430
3386
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
2431
3387
|
|
|
2432
3388
|
const evicted = data?.evictedPods?.length ?? 0
|
|
@@ -2462,11 +3418,11 @@ export function useHelmReleases(namespaces: string[] = []) {
|
|
|
2462
3418
|
}
|
|
2463
3419
|
|
|
2464
3420
|
// Get details for a specific Helm release
|
|
2465
|
-
export function useHelmRelease(namespace: string, name: string) {
|
|
3421
|
+
export function useHelmRelease(namespace: string, name: string, options?: { enabled?: boolean }) {
|
|
2466
3422
|
return useQuery<HelmReleaseDetail>({
|
|
2467
3423
|
queryKey: ['helm-release', namespace, name],
|
|
2468
3424
|
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}`),
|
|
2469
|
-
enabled: Boolean(namespace && name),
|
|
3425
|
+
enabled: Boolean(namespace && name) && (options?.enabled ?? true),
|
|
2470
3426
|
staleTime: 5000,
|
|
2471
3427
|
refetchInterval: 10000, // Poll for live resource status updates (post-upgrade/rollback)
|
|
2472
3428
|
})
|
|
@@ -2476,12 +3432,19 @@ export function useHelmRelease(namespace: string, name: string) {
|
|
|
2476
3432
|
// `enabled` lets callers skip the query when the user's Cloud role
|
|
2477
3433
|
// would 403 the read — saves a round-trip and avoids a transient
|
|
2478
3434
|
// "error" state that the role-gated empty panel doesn't need.
|
|
2479
|
-
export function useHelmManifest(
|
|
3435
|
+
export function useHelmManifest(
|
|
3436
|
+
namespace: string,
|
|
3437
|
+
name: string,
|
|
3438
|
+
revision?: number,
|
|
3439
|
+
enabled = true,
|
|
3440
|
+
) {
|
|
2480
3441
|
const params = revision ? `?revision=${revision}` : ''
|
|
2481
3442
|
return useQuery<string>({
|
|
2482
3443
|
queryKey: ['helm-manifest', namespace, name, revision],
|
|
2483
3444
|
queryFn: async () => {
|
|
2484
|
-
const response = await apiFetch(
|
|
3445
|
+
const response = await apiFetch(
|
|
3446
|
+
`${getApiBase()}/helm/releases/${namespace}/${name}/manifest${params}`,
|
|
3447
|
+
)
|
|
2485
3448
|
if (!response.ok) {
|
|
2486
3449
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2487
3450
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -2494,7 +3457,13 @@ export function useHelmManifest(namespace: string, name: string, revision?: numb
|
|
|
2494
3457
|
}
|
|
2495
3458
|
|
|
2496
3459
|
// Get values for a Helm release. `enabled` see useHelmManifest.
|
|
2497
|
-
export function useHelmValues(
|
|
3460
|
+
export function useHelmValues(
|
|
3461
|
+
namespace: string,
|
|
3462
|
+
name: string,
|
|
3463
|
+
allValues?: boolean,
|
|
3464
|
+
enabled = true,
|
|
3465
|
+
revision?: number,
|
|
3466
|
+
) {
|
|
2498
3467
|
const params = new URLSearchParams()
|
|
2499
3468
|
if (allValues) params.set('all', 'true')
|
|
2500
3469
|
if (revision && revision > 0) params.set('revision', String(revision))
|
|
@@ -2518,8 +3487,12 @@ export function useHelmManifestDiff(
|
|
|
2518
3487
|
return useQuery<ManifestDiff>({
|
|
2519
3488
|
queryKey: ['helm-diff', namespace, name, revision1, revision2],
|
|
2520
3489
|
queryFn: () =>
|
|
2521
|
-
fetchJSON(
|
|
2522
|
-
|
|
3490
|
+
fetchJSON(
|
|
3491
|
+
`/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`,
|
|
3492
|
+
),
|
|
3493
|
+
enabled: Boolean(
|
|
3494
|
+
namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
|
|
3495
|
+
),
|
|
2523
3496
|
staleTime: 60000,
|
|
2524
3497
|
})
|
|
2525
3498
|
}
|
|
@@ -2542,7 +3515,9 @@ export function useHelmValuesDiff(
|
|
|
2542
3515
|
if (allValues) params.set('all', 'true')
|
|
2543
3516
|
return fetchJSON(`/helm/releases/${namespace}/${name}/values/diff?${params.toString()}`)
|
|
2544
3517
|
},
|
|
2545
|
-
enabled: Boolean(
|
|
3518
|
+
enabled: Boolean(
|
|
3519
|
+
namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
|
|
3520
|
+
),
|
|
2546
3521
|
staleTime: 60000,
|
|
2547
3522
|
})
|
|
2548
3523
|
}
|
|
@@ -2557,8 +3532,12 @@ export function useHelmNotesDiff(
|
|
|
2557
3532
|
return useQuery<NotesDiff>({
|
|
2558
3533
|
queryKey: ['helm-notes-diff', namespace, name, revision1, revision2],
|
|
2559
3534
|
queryFn: () =>
|
|
2560
|
-
fetchJSON(
|
|
2561
|
-
|
|
3535
|
+
fetchJSON(
|
|
3536
|
+
`/helm/releases/${namespace}/${name}/notes/diff?revision1=${revision1}&revision2=${revision2}`,
|
|
3537
|
+
),
|
|
3538
|
+
enabled: Boolean(
|
|
3539
|
+
namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
|
|
3540
|
+
),
|
|
2562
3541
|
staleTime: 60000,
|
|
2563
3542
|
})
|
|
2564
3543
|
}
|
|
@@ -2573,8 +3552,12 @@ export function useHelmHooksDiff(
|
|
|
2573
3552
|
return useQuery<HooksDiff>({
|
|
2574
3553
|
queryKey: ['helm-hooks-diff', namespace, name, revision1, revision2],
|
|
2575
3554
|
queryFn: () =>
|
|
2576
|
-
fetchJSON(
|
|
2577
|
-
|
|
3555
|
+
fetchJSON(
|
|
3556
|
+
`/helm/releases/${namespace}/${name}/hooks/diff?revision1=${revision1}&revision2=${revision2}`,
|
|
3557
|
+
),
|
|
3558
|
+
enabled: Boolean(
|
|
3559
|
+
namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
|
|
3560
|
+
),
|
|
2578
3561
|
staleTime: 60000,
|
|
2579
3562
|
})
|
|
2580
3563
|
}
|
|
@@ -2589,8 +3572,12 @@ export function useHelmResourceDiff(
|
|
|
2589
3572
|
return useQuery<ResourceDiff>({
|
|
2590
3573
|
queryKey: ['helm-resource-diff', namespace, name, revision1, revision2],
|
|
2591
3574
|
queryFn: () =>
|
|
2592
|
-
fetchJSON(
|
|
2593
|
-
|
|
3575
|
+
fetchJSON(
|
|
3576
|
+
`/helm/releases/${namespace}/${name}/resources/diff?revision1=${revision1}&revision2=${revision2}`,
|
|
3577
|
+
),
|
|
3578
|
+
enabled: Boolean(
|
|
3579
|
+
namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled,
|
|
3580
|
+
),
|
|
2594
3581
|
staleTime: 60000,
|
|
2595
3582
|
})
|
|
2596
3583
|
}
|
|
@@ -2640,10 +3627,21 @@ export function useHelmRollback() {
|
|
|
2640
3627
|
const queryClient = useQueryClient()
|
|
2641
3628
|
|
|
2642
3629
|
return useMutation({
|
|
2643
|
-
mutationFn: async ({
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
3630
|
+
mutationFn: async ({
|
|
3631
|
+
namespace,
|
|
3632
|
+
name,
|
|
3633
|
+
revision,
|
|
3634
|
+
}: {
|
|
3635
|
+
namespace: string
|
|
3636
|
+
name: string
|
|
3637
|
+
revision: number
|
|
3638
|
+
}) => {
|
|
3639
|
+
const response = await apiFetch(
|
|
3640
|
+
`${getApiBase()}/helm/releases/${namespace}/${name}/rollback?revision=${revision}`,
|
|
3641
|
+
{
|
|
3642
|
+
method: 'POST',
|
|
3643
|
+
},
|
|
3644
|
+
)
|
|
2647
3645
|
if (!response.ok) {
|
|
2648
3646
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2649
3647
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -2656,7 +3654,9 @@ export function useHelmRollback() {
|
|
|
2656
3654
|
},
|
|
2657
3655
|
onSuccess: (_, variables) => {
|
|
2658
3656
|
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
2659
|
-
queryClient.invalidateQueries({
|
|
3657
|
+
queryClient.invalidateQueries({
|
|
3658
|
+
queryKey: ['helm-release', variables.namespace, variables.name],
|
|
3659
|
+
})
|
|
2660
3660
|
},
|
|
2661
3661
|
})
|
|
2662
3662
|
}
|
|
@@ -2740,7 +3740,9 @@ function streamHelmProgress(
|
|
|
2740
3740
|
return
|
|
2741
3741
|
}
|
|
2742
3742
|
} catch (err) {
|
|
2743
|
-
reject(
|
|
3743
|
+
reject(
|
|
3744
|
+
err instanceof Error ? err : new Error(`${failureLabel}: invalid progress event`),
|
|
3745
|
+
)
|
|
2744
3746
|
return
|
|
2745
3747
|
}
|
|
2746
3748
|
}
|
|
@@ -2753,19 +3755,28 @@ function streamHelmProgress(
|
|
|
2753
3755
|
})
|
|
2754
3756
|
}
|
|
2755
3757
|
|
|
2756
|
-
//
|
|
3758
|
+
// When `values` is provided, the upgrade applies exactly those edited values
|
|
3759
|
+
// instead of carrying the release's prior values over blindly.
|
|
2757
3760
|
export function upgradeWithProgress(
|
|
2758
3761
|
namespace: string,
|
|
2759
3762
|
name: string,
|
|
2760
3763
|
version: string,
|
|
2761
3764
|
repositoryName: string | undefined,
|
|
2762
|
-
onProgress: (event: InstallProgressEvent) => void
|
|
3765
|
+
onProgress: (event: InstallProgressEvent) => void,
|
|
3766
|
+
values?: Record<string, unknown>,
|
|
2763
3767
|
): Promise<void> {
|
|
2764
3768
|
const params = new URLSearchParams({ version })
|
|
2765
3769
|
if (repositoryName) params.set('repository', repositoryName)
|
|
3770
|
+
const options: RequestInit = values
|
|
3771
|
+
? {
|
|
3772
|
+
method: 'POST',
|
|
3773
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3774
|
+
body: JSON.stringify({ values }),
|
|
3775
|
+
}
|
|
3776
|
+
: { method: 'POST' }
|
|
2766
3777
|
return streamHelmProgress(
|
|
2767
3778
|
`${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?${params.toString()}`,
|
|
2768
|
-
|
|
3779
|
+
options,
|
|
2769
3780
|
onProgress,
|
|
2770
3781
|
'Upgrade failed',
|
|
2771
3782
|
).then(() => {})
|
|
@@ -2776,7 +3787,7 @@ export function rollbackWithProgress(
|
|
|
2776
3787
|
namespace: string,
|
|
2777
3788
|
name: string,
|
|
2778
3789
|
revision: number,
|
|
2779
|
-
onProgress: (event: InstallProgressEvent) => void
|
|
3790
|
+
onProgress: (event: InstallProgressEvent) => void,
|
|
2780
3791
|
): Promise<void> {
|
|
2781
3792
|
return streamHelmProgress(
|
|
2782
3793
|
`${getApiBase()}/helm/releases/${namespace}/${name}/rollback-stream?revision=${revision}`,
|
|
@@ -2786,15 +3797,29 @@ export function rollbackWithProgress(
|
|
|
2786
3797
|
).then(() => {})
|
|
2787
3798
|
}
|
|
2788
3799
|
|
|
2789
|
-
//
|
|
3800
|
+
// When `version` is supplied, preview renders against that target chart version
|
|
3801
|
+
// instead of the release's current chart.
|
|
2790
3802
|
export function useHelmPreviewValues() {
|
|
2791
|
-
return useMutation<
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
3803
|
+
return useMutation<
|
|
3804
|
+
ValuesPreviewResponse,
|
|
3805
|
+
Error,
|
|
3806
|
+
{
|
|
3807
|
+
namespace: string
|
|
3808
|
+
name: string
|
|
3809
|
+
values: Record<string, unknown>
|
|
3810
|
+
version?: string
|
|
3811
|
+
repository?: string
|
|
3812
|
+
}
|
|
3813
|
+
>({
|
|
3814
|
+
mutationFn: async ({ namespace, name, values, version, repository }) => {
|
|
3815
|
+
const response = await apiFetch(
|
|
3816
|
+
`${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`,
|
|
3817
|
+
{
|
|
3818
|
+
method: 'POST',
|
|
3819
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3820
|
+
body: JSON.stringify({ values, version, repository }),
|
|
3821
|
+
},
|
|
3822
|
+
)
|
|
2798
3823
|
if (!response.ok) {
|
|
2799
3824
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2800
3825
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -2809,7 +3834,15 @@ export function useHelmApplyValues() {
|
|
|
2809
3834
|
const queryClient = useQueryClient()
|
|
2810
3835
|
|
|
2811
3836
|
return useMutation({
|
|
2812
|
-
mutationFn: async ({
|
|
3837
|
+
mutationFn: async ({
|
|
3838
|
+
namespace,
|
|
3839
|
+
name,
|
|
3840
|
+
values,
|
|
3841
|
+
}: {
|
|
3842
|
+
namespace: string
|
|
3843
|
+
name: string
|
|
3844
|
+
values: Record<string, unknown>
|
|
3845
|
+
}) => {
|
|
2813
3846
|
const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values`, {
|
|
2814
3847
|
method: 'PUT',
|
|
2815
3848
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -2827,8 +3860,12 @@ export function useHelmApplyValues() {
|
|
|
2827
3860
|
},
|
|
2828
3861
|
onSuccess: (_, variables) => {
|
|
2829
3862
|
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
2830
|
-
queryClient.invalidateQueries({
|
|
2831
|
-
|
|
3863
|
+
queryClient.invalidateQueries({
|
|
3864
|
+
queryKey: ['helm-release', variables.namespace, variables.name],
|
|
3865
|
+
})
|
|
3866
|
+
queryClient.invalidateQueries({
|
|
3867
|
+
queryKey: ['helm-values', variables.namespace, variables.name],
|
|
3868
|
+
})
|
|
2832
3869
|
},
|
|
2833
3870
|
})
|
|
2834
3871
|
}
|
|
@@ -2925,7 +3962,10 @@ export function useAddOCISource() {
|
|
|
2925
3962
|
const queryClient = useQueryClient()
|
|
2926
3963
|
return useMutation({
|
|
2927
3964
|
mutationFn: (source: string) => mutateOCISource('POST', source),
|
|
2928
|
-
meta: {
|
|
3965
|
+
meta: {
|
|
3966
|
+
errorMessage: 'Failed to add chart source',
|
|
3967
|
+
successMessage: 'Chart source added',
|
|
3968
|
+
},
|
|
2929
3969
|
onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
|
|
2930
3970
|
})
|
|
2931
3971
|
}
|
|
@@ -2934,7 +3974,10 @@ export function useRemoveOCISource() {
|
|
|
2934
3974
|
const queryClient = useQueryClient()
|
|
2935
3975
|
return useMutation({
|
|
2936
3976
|
mutationFn: (source: string) => mutateOCISource('DELETE', source),
|
|
2937
|
-
meta: {
|
|
3977
|
+
meta: {
|
|
3978
|
+
errorMessage: 'Failed to remove chart source',
|
|
3979
|
+
successMessage: 'Chart source removed',
|
|
3980
|
+
},
|
|
2938
3981
|
onSuccess: () => invalidateHelmAfterSourceChange(queryClient),
|
|
2939
3982
|
})
|
|
2940
3983
|
}
|
|
@@ -3006,11 +4049,15 @@ export interface InstallProgressEvent {
|
|
|
3006
4049
|
// Install a chart with progress streaming via SSE
|
|
3007
4050
|
export function installChartWithProgress(
|
|
3008
4051
|
req: InstallChartRequest,
|
|
3009
|
-
onProgress: (event: InstallProgressEvent) => void
|
|
4052
|
+
onProgress: (event: InstallProgressEvent) => void,
|
|
3010
4053
|
): Promise<HelmRelease> {
|
|
3011
4054
|
return streamHelmProgress(
|
|
3012
4055
|
`${getApiBase()}/helm/releases/install-stream`,
|
|
3013
|
-
{
|
|
4056
|
+
{
|
|
4057
|
+
method: 'POST',
|
|
4058
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4059
|
+
body: JSON.stringify(req),
|
|
4060
|
+
},
|
|
3014
4061
|
onProgress,
|
|
3015
4062
|
'Install failed',
|
|
3016
4063
|
).then((event) => event.release as HelmRelease)
|
|
@@ -3026,8 +4073,14 @@ export type ArtifactHubSortOption = 'relevance' | 'stars' | 'last_updated'
|
|
|
3026
4073
|
// Search charts on ArtifactHub
|
|
3027
4074
|
export function useArtifactHubSearch(
|
|
3028
4075
|
query: string,
|
|
3029
|
-
options?: {
|
|
3030
|
-
|
|
4076
|
+
options?: {
|
|
4077
|
+
offset?: number
|
|
4078
|
+
limit?: number
|
|
4079
|
+
official?: boolean
|
|
4080
|
+
verified?: boolean
|
|
4081
|
+
sort?: ArtifactHubSortOption
|
|
4082
|
+
},
|
|
4083
|
+
enabled = true,
|
|
3031
4084
|
) {
|
|
3032
4085
|
const params = new URLSearchParams()
|
|
3033
4086
|
if (query) params.set('query', query)
|
|
@@ -3038,7 +4091,15 @@ export function useArtifactHubSearch(
|
|
|
3038
4091
|
if (options?.sort && options.sort !== 'relevance') params.set('sort', options.sort)
|
|
3039
4092
|
|
|
3040
4093
|
return useQuery<ArtifactHubSearchResult>({
|
|
3041
|
-
queryKey: [
|
|
4094
|
+
queryKey: [
|
|
4095
|
+
'artifacthub-search',
|
|
4096
|
+
query,
|
|
4097
|
+
options?.offset,
|
|
4098
|
+
options?.limit,
|
|
4099
|
+
options?.official,
|
|
4100
|
+
options?.verified,
|
|
4101
|
+
options?.sort,
|
|
4102
|
+
],
|
|
3042
4103
|
queryFn: () => fetchJSON(`/helm/artifacthub/search?${params.toString()}`),
|
|
3043
4104
|
enabled: enabled && query.length > 0,
|
|
3044
4105
|
staleTime: 60000, // 1 minute
|
|
@@ -3046,7 +4107,12 @@ export function useArtifactHubSearch(
|
|
|
3046
4107
|
}
|
|
3047
4108
|
|
|
3048
4109
|
// Get chart detail from ArtifactHub
|
|
3049
|
-
export function useArtifactHubChart(
|
|
4110
|
+
export function useArtifactHubChart(
|
|
4111
|
+
repoName: string,
|
|
4112
|
+
chartName: string,
|
|
4113
|
+
version?: string,
|
|
4114
|
+
enabled = true,
|
|
4115
|
+
) {
|
|
3050
4116
|
const path = version
|
|
3051
4117
|
? `/helm/artifacthub/charts/${repoName}/${chartName}/${version}`
|
|
3052
4118
|
: `/helm/artifacthub/charts/${repoName}/${chartName}`
|
|
@@ -3067,7 +4133,8 @@ interface GitOpsMutationConfig<TVariables> {
|
|
|
3067
4133
|
getPath: (variables: TVariables) => string
|
|
3068
4134
|
getBody?: (variables: TVariables) => unknown
|
|
3069
4135
|
errorMessage: string
|
|
3070
|
-
successMessage
|
|
4136
|
+
successMessage?: string
|
|
4137
|
+
getSuccessMessage?: (data: GitOpsOperationResponse) => string
|
|
3071
4138
|
getInvalidateKeys: (variables: TVariables) => (string | undefined)[][]
|
|
3072
4139
|
}
|
|
3073
4140
|
|
|
@@ -3095,9 +4162,10 @@ function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariable
|
|
|
3095
4162
|
errorMessage: config.errorMessage,
|
|
3096
4163
|
successMessage: config.successMessage,
|
|
3097
4164
|
},
|
|
3098
|
-
onSuccess: (
|
|
3099
|
-
config.
|
|
3100
|
-
|
|
4165
|
+
onSuccess: (data, variables) => {
|
|
4166
|
+
if (config.getSuccessMessage) showApiSuccess(config.getSuccessMessage(data))
|
|
4167
|
+
config.getInvalidateKeys(variables).forEach((key) =>
|
|
4168
|
+
queryClient.invalidateQueries({ queryKey: key }),
|
|
3101
4169
|
)
|
|
3102
4170
|
},
|
|
3103
4171
|
})
|
|
@@ -3112,8 +4180,13 @@ type ArgoAppVars = { namespace: string; name: string }
|
|
|
3112
4180
|
// ArgoSyncVars extends ArgoAppVars with the sync request body fields. Only
|
|
3113
4181
|
// useArgoSync sends these — splitting the type prevents callers from passing
|
|
3114
4182
|
// resources/revision/prune to mutations that would silently drop them.
|
|
3115
|
-
type ArgoSyncVars = ArgoAppVars & {
|
|
3116
|
-
resources?: Array<{
|
|
4183
|
+
export type ArgoSyncVars = ArgoAppVars & {
|
|
4184
|
+
resources?: Array<{
|
|
4185
|
+
group?: string
|
|
4186
|
+
kind: string
|
|
4187
|
+
namespace?: string
|
|
4188
|
+
name: string
|
|
4189
|
+
}>
|
|
3117
4190
|
revision?: string
|
|
3118
4191
|
prune?: boolean
|
|
3119
4192
|
dryRun?: boolean
|
|
@@ -3125,6 +4198,31 @@ type ArgoSyncVars = ArgoAppVars & {
|
|
|
3125
4198
|
syncOptions?: string[]
|
|
3126
4199
|
}
|
|
3127
4200
|
|
|
4201
|
+
export interface ArgoResourceValidationResult {
|
|
4202
|
+
outcome: 'succeeded' | 'failed' | 'inconclusive'
|
|
4203
|
+
message: string
|
|
4204
|
+
resource?: {
|
|
4205
|
+
group?: string
|
|
4206
|
+
kind: string
|
|
4207
|
+
namespace?: string
|
|
4208
|
+
name: string
|
|
4209
|
+
status?: string
|
|
4210
|
+
message?: string
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4214
|
+
export function buildArgoResourceSyncVars(namespace: string, name: string, resource: GitOpsInsightRef, opts: ArgoSyncOpts): ArgoSyncVars {
|
|
4215
|
+
return {
|
|
4216
|
+
namespace,
|
|
4217
|
+
name,
|
|
4218
|
+
...opts,
|
|
4219
|
+
resources: [{ group: resource.group, kind: resource.kind, namespace: resource.namespace, name: resource.name }],
|
|
4220
|
+
revision: undefined,
|
|
4221
|
+
prune: false,
|
|
4222
|
+
applyOnly: false,
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
|
|
3128
4226
|
// ArgoRollbackVars targets a specific Argo history entry by ID. Prune and
|
|
3129
4227
|
// DryRun mirror the sync flags so the rollback dialog can offer the same
|
|
3130
4228
|
// safety net.
|
|
@@ -3206,6 +4304,31 @@ export const useArgoSync = createGitOpsMutation<ArgoSyncVars>({
|
|
|
3206
4304
|
getInvalidateKeys: argoInvalidateKeys,
|
|
3207
4305
|
})
|
|
3208
4306
|
|
|
4307
|
+
export function useArgoResourceValidation() {
|
|
4308
|
+
const queryClient = useQueryClient()
|
|
4309
|
+
return useMutation<ArgoResourceValidationResult, Error, ArgoSyncVars>({
|
|
4310
|
+
mutationFn: async (variables) => {
|
|
4311
|
+
const response = await apiFetch(`${getApiBase()}/argo/applications/${variables.namespace}/${variables.name}/validate-resource`, {
|
|
4312
|
+
method: 'POST',
|
|
4313
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4314
|
+
body: JSON.stringify({
|
|
4315
|
+
resources: variables.resources,
|
|
4316
|
+
force: variables.force,
|
|
4317
|
+
syncOptions: variables.syncOptions,
|
|
4318
|
+
}),
|
|
4319
|
+
})
|
|
4320
|
+
if (!response.ok) {
|
|
4321
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
4322
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
4323
|
+
}
|
|
4324
|
+
return response.json() as Promise<ArgoResourceValidationResult>
|
|
4325
|
+
},
|
|
4326
|
+
onSettled: (_, __, variables) => {
|
|
4327
|
+
argoInvalidateKeys(variables).forEach(key => queryClient.invalidateQueries({ queryKey: key }))
|
|
4328
|
+
},
|
|
4329
|
+
})
|
|
4330
|
+
}
|
|
4331
|
+
|
|
3209
4332
|
export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
|
|
3210
4333
|
getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/rollback`,
|
|
3211
4334
|
getBody: (v) => ({ id: v.id, prune: v.prune, dryRun: v.dryRun }),
|
|
@@ -3217,7 +4340,7 @@ export const useArgoRollback = createGitOpsMutation<ArgoRollbackVars>({
|
|
|
3217
4340
|
export const useArgoTerminate = createGitOpsMutation<ArgoAppVars>({
|
|
3218
4341
|
getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/terminate`,
|
|
3219
4342
|
errorMessage: 'Failed to terminate sync',
|
|
3220
|
-
|
|
4343
|
+
getSuccessMessage: (data) => data.message,
|
|
3221
4344
|
getInvalidateKeys: argoInvalidateKeys,
|
|
3222
4345
|
})
|
|
3223
4346
|
|
|
@@ -3240,11 +4363,22 @@ export function useArgoRefresh() {
|
|
|
3240
4363
|
const queryClient = useQueryClient()
|
|
3241
4364
|
|
|
3242
4365
|
return useMutation({
|
|
3243
|
-
mutationFn: async ({
|
|
4366
|
+
mutationFn: async ({
|
|
4367
|
+
namespace,
|
|
4368
|
+
name,
|
|
4369
|
+
hard = false,
|
|
4370
|
+
}: {
|
|
4371
|
+
namespace: string
|
|
4372
|
+
name: string
|
|
4373
|
+
hard?: boolean
|
|
4374
|
+
}) => {
|
|
3244
4375
|
const params = hard ? '?type=hard' : ''
|
|
3245
|
-
const response = await apiFetch(
|
|
3246
|
-
|
|
3247
|
-
|
|
4376
|
+
const response = await apiFetch(
|
|
4377
|
+
`${getApiBase()}/argo/applications/${namespace}/${name}/refresh${params}`,
|
|
4378
|
+
{
|
|
4379
|
+
method: 'POST',
|
|
4380
|
+
},
|
|
4381
|
+
)
|
|
3248
4382
|
if (!response.ok) {
|
|
3249
4383
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
3250
4384
|
throw new Error(error.error || `HTTP ${response.status}`)
|
|
@@ -3261,7 +4395,7 @@ export function useArgoRefresh() {
|
|
|
3261
4395
|
// Refresh — without these two extra keys the user clicks Refresh and
|
|
3262
4396
|
// sees stale insight/tree data until the next staleTime tick.
|
|
3263
4397
|
argoInvalidateKeys(variables).forEach((key) =>
|
|
3264
|
-
queryClient.invalidateQueries({ queryKey: key })
|
|
4398
|
+
queryClient.invalidateQueries({ queryKey: key }),
|
|
3265
4399
|
)
|
|
3266
4400
|
},
|
|
3267
4401
|
})
|
|
@@ -3319,7 +4453,9 @@ export function useSwitchContext() {
|
|
|
3319
4453
|
} catch (error) {
|
|
3320
4454
|
clearTimeout(timeoutId)
|
|
3321
4455
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
3322
|
-
throw new Error('Context switch timed out. The cluster may be unreachable.', {
|
|
4456
|
+
throw new Error('Context switch timed out. The cluster may be unreachable.', {
|
|
4457
|
+
cause: error,
|
|
4458
|
+
})
|
|
3323
4459
|
}
|
|
3324
4460
|
throw error
|
|
3325
4461
|
}
|
|
@@ -3437,9 +4573,12 @@ export function useSetActiveNamespace() {
|
|
|
3437
4573
|
error: error instanceof Error ? error.message : String(error),
|
|
3438
4574
|
})
|
|
3439
4575
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
3440
|
-
throw new Error(
|
|
3441
|
-
|
|
3442
|
-
|
|
4576
|
+
throw new Error(
|
|
4577
|
+
isRescope
|
|
4578
|
+
? 'Namespace rescope timed out. The cluster may still be loading.'
|
|
4579
|
+
: 'Namespace switch timed out. The cluster may be unreachable.',
|
|
4580
|
+
{ cause: error },
|
|
4581
|
+
)
|
|
3443
4582
|
}
|
|
3444
4583
|
throw error
|
|
3445
4584
|
}
|
|
@@ -3451,7 +4590,9 @@ export function useSetActiveNamespace() {
|
|
|
3451
4590
|
accessibleCount: scope.accessibleNamespaces.length,
|
|
3452
4591
|
})
|
|
3453
4592
|
if (scope.cacheScoped) {
|
|
3454
|
-
queryClient.removeQueries({
|
|
4593
|
+
queryClient.removeQueries({
|
|
4594
|
+
predicate: (query) => query.queryKey[0] !== 'namespace-scope',
|
|
4595
|
+
})
|
|
3455
4596
|
}
|
|
3456
4597
|
queryClient.setQueryData<NamespaceScope>(['namespace-scope'], scope)
|
|
3457
4598
|
if (scope.cacheScoped) {
|
|
@@ -3482,7 +4623,7 @@ export function useImageMetadata(
|
|
|
3482
4623
|
namespace: string,
|
|
3483
4624
|
podName: string,
|
|
3484
4625
|
pullSecrets: string[],
|
|
3485
|
-
enabled = true
|
|
4626
|
+
enabled = true,
|
|
3486
4627
|
) {
|
|
3487
4628
|
const params = new URLSearchParams()
|
|
3488
4629
|
params.set('image', image)
|
|
@@ -3505,7 +4646,7 @@ export function useImageFilesystem(
|
|
|
3505
4646
|
namespace: string,
|
|
3506
4647
|
podName: string,
|
|
3507
4648
|
pullSecrets: string[],
|
|
3508
|
-
enabled = true
|
|
4649
|
+
enabled = true,
|
|
3509
4650
|
) {
|
|
3510
4651
|
const params = new URLSearchParams()
|
|
3511
4652
|
params.set('image', image)
|
|
@@ -3518,9 +4659,7 @@ export function useImageFilesystem(
|
|
|
3518
4659
|
return useQuery<ImageFilesystem>({
|
|
3519
4660
|
queryKey: ['image-filesystem', image, namespace, podName, pullSecrets.join(',')],
|
|
3520
4661
|
// Use skipToken to completely prevent the query from running when disabled
|
|
3521
|
-
queryFn: shouldFetch
|
|
3522
|
-
? () => fetchJSON(`/images/inspect?${params.toString()}`)
|
|
3523
|
-
: skipToken,
|
|
4662
|
+
queryFn: shouldFetch ? () => fetchJSON(`/images/inspect?${params.toString()}`) : skipToken,
|
|
3524
4663
|
staleTime: 300000, // 5 minutes - image content doesn't change
|
|
3525
4664
|
retry: false, // Don't retry on auth errors
|
|
3526
4665
|
})
|
|
@@ -3544,6 +4683,44 @@ export interface WorkloadLogsResponse {
|
|
|
3544
4683
|
timestamp: string
|
|
3545
4684
|
content: string
|
|
3546
4685
|
}[]
|
|
4686
|
+
emptyReason?: string
|
|
4687
|
+
emptyMessage?: string
|
|
4688
|
+
command?: string
|
|
4689
|
+
}
|
|
4690
|
+
|
|
4691
|
+
export interface WorkloadRun {
|
|
4692
|
+
kind: string
|
|
4693
|
+
namespace: string
|
|
4694
|
+
name: string
|
|
4695
|
+
phase: string
|
|
4696
|
+
active: boolean
|
|
4697
|
+
startedAt?: string
|
|
4698
|
+
finishedAt?: string
|
|
4699
|
+
scheduledAt?: string
|
|
4700
|
+
trigger?: 'manual' | 'schedule' | string
|
|
4701
|
+
message?: string
|
|
4702
|
+
succeeded?: number
|
|
4703
|
+
failed?: number
|
|
4704
|
+
running?: number
|
|
4705
|
+
desired?: number
|
|
4706
|
+
parallelism?: number
|
|
4707
|
+
progress?: string
|
|
4708
|
+
template?: string
|
|
4709
|
+
launcher?: {
|
|
4710
|
+
kind: string
|
|
4711
|
+
namespace?: string
|
|
4712
|
+
name: string
|
|
4713
|
+
group?: string
|
|
4714
|
+
}
|
|
4715
|
+
podTotal?: number
|
|
4716
|
+
podSucceeded?: number
|
|
4717
|
+
podFailed?: number
|
|
4718
|
+
podRunning?: number
|
|
4719
|
+
podPending?: number
|
|
4720
|
+
}
|
|
4721
|
+
|
|
4722
|
+
export interface WorkloadRunsResponse {
|
|
4723
|
+
runs: WorkloadRun[]
|
|
3547
4724
|
}
|
|
3548
4725
|
|
|
3549
4726
|
// Fetch pods for a workload
|
|
@@ -3556,6 +4733,31 @@ export function useWorkloadPods(kind: string, namespace: string, name: string) {
|
|
|
3556
4733
|
})
|
|
3557
4734
|
}
|
|
3558
4735
|
|
|
4736
|
+
export function useWorkloadRuns(
|
|
4737
|
+
kind: string,
|
|
4738
|
+
namespace: string,
|
|
4739
|
+
name: string,
|
|
4740
|
+
enabled = true,
|
|
4741
|
+
options?: { refetchActive?: boolean; clusterScoped?: boolean },
|
|
4742
|
+
) {
|
|
4743
|
+
const clusterScoped = options?.clusterScoped ?? false
|
|
4744
|
+
const ns = clusterScoped ? '_' : namespace
|
|
4745
|
+
const params = new URLSearchParams()
|
|
4746
|
+
if (clusterScoped) params.set('clusterScoped', 'true')
|
|
4747
|
+
const queryString = params.toString()
|
|
4748
|
+
|
|
4749
|
+
return useQuery<WorkloadRunsResponse>({
|
|
4750
|
+
queryKey: ['workload-runs', kind, namespace, name, clusterScoped],
|
|
4751
|
+
queryFn: () =>
|
|
4752
|
+
fetchJSON(`/workloads/${kind}/${ns}/${name}/runs${queryString ? `?${queryString}` : ''}`),
|
|
4753
|
+
enabled: enabled && Boolean(kind && name && (namespace || clusterScoped)),
|
|
4754
|
+
staleTime: 10000,
|
|
4755
|
+
refetchInterval: options?.refetchActive
|
|
4756
|
+
? (query) => (query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000)
|
|
4757
|
+
: false,
|
|
4758
|
+
})
|
|
4759
|
+
}
|
|
4760
|
+
|
|
3559
4761
|
// Fetch logs for a workload (non-streaming)
|
|
3560
4762
|
export function useWorkloadLogs(
|
|
3561
4763
|
kind: string,
|
|
@@ -3565,7 +4767,7 @@ export function useWorkloadLogs(
|
|
|
3565
4767
|
container?: string
|
|
3566
4768
|
tailLines?: number
|
|
3567
4769
|
sinceSeconds?: number
|
|
3568
|
-
}
|
|
4770
|
+
},
|
|
3569
4771
|
) {
|
|
3570
4772
|
const params = new URLSearchParams()
|
|
3571
4773
|
if (options?.container) params.set('container', options.container)
|
|
@@ -3574,8 +4776,19 @@ export function useWorkloadLogs(
|
|
|
3574
4776
|
const queryString = params.toString()
|
|
3575
4777
|
|
|
3576
4778
|
return useQuery<WorkloadLogsResponse>({
|
|
3577
|
-
queryKey: [
|
|
3578
|
-
|
|
4779
|
+
queryKey: [
|
|
4780
|
+
'workload-logs',
|
|
4781
|
+
kind,
|
|
4782
|
+
namespace,
|
|
4783
|
+
name,
|
|
4784
|
+
options?.container,
|
|
4785
|
+
options?.tailLines,
|
|
4786
|
+
options?.sinceSeconds,
|
|
4787
|
+
],
|
|
4788
|
+
queryFn: () =>
|
|
4789
|
+
fetchJSON(
|
|
4790
|
+
`/workloads/${kind}/${namespace}/${name}/logs${queryString ? `?${queryString}` : ''}`,
|
|
4791
|
+
),
|
|
3579
4792
|
enabled: Boolean(kind && namespace && name),
|
|
3580
4793
|
staleTime: 5000,
|
|
3581
4794
|
})
|
|
@@ -3590,7 +4803,7 @@ export function createWorkloadLogStream(
|
|
|
3590
4803
|
container?: string
|
|
3591
4804
|
tailLines?: number
|
|
3592
4805
|
sinceSeconds?: number
|
|
3593
|
-
}
|
|
4806
|
+
},
|
|
3594
4807
|
): EventSource {
|
|
3595
4808
|
const params = new URLSearchParams()
|
|
3596
4809
|
if (options?.container) params.set('container', options.container)
|
|
@@ -3598,9 +4811,12 @@ export function createWorkloadLogStream(
|
|
|
3598
4811
|
if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
|
|
3599
4812
|
const queryString = params.toString()
|
|
3600
4813
|
|
|
3601
|
-
return new EventSource(
|
|
3602
|
-
|
|
3603
|
-
|
|
4814
|
+
return new EventSource(
|
|
4815
|
+
`${getApiBase()}/workloads/${kind}/${namespace}/${name}/logs/stream${queryString ? `?${queryString}` : ''}`,
|
|
4816
|
+
{
|
|
4817
|
+
withCredentials: getCredentialsMode() === 'include',
|
|
4818
|
+
},
|
|
4819
|
+
)
|
|
3604
4820
|
}
|
|
3605
4821
|
|
|
3606
4822
|
// ============================================================================
|