@skyhook-io/radar-app 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -0
- package/package.json +80 -0
- package/src/App.tsx +1538 -0
- package/src/RadarApp.tsx +145 -0
- package/src/api/apiResources.ts +28 -0
- package/src/api/client.ts +2583 -0
- package/src/api/config.ts +116 -0
- package/src/api/traffic.ts +139 -0
- package/src/components/ConnectionErrorView.tsx +272 -0
- package/src/components/ContextSwitcher.tsx +481 -0
- package/src/components/DebugOverlay.tsx +94 -0
- package/src/components/UserMenu.tsx +87 -0
- package/src/components/audit/AuditSettingsDialog.tsx +162 -0
- package/src/components/audit/AuditView.tsx +123 -0
- package/src/components/cost/CostTrendChart.tsx +388 -0
- package/src/components/cost/CostView.tsx +545 -0
- package/src/components/dock/BottomDock.tsx +96 -0
- package/src/components/dock/DockContext.tsx +11 -0
- package/src/components/dock/LocalTerminalTab.tsx +22 -0
- package/src/components/dock/LogsTab.tsx +26 -0
- package/src/components/dock/NodeTerminalTab.tsx +50 -0
- package/src/components/dock/TerminalTab.tsx +42 -0
- package/src/components/dock/TrafficFlowListTab.tsx +18 -0
- package/src/components/dock/WorkloadLogsTab.tsx +23 -0
- package/src/components/dock/index.ts +2 -0
- package/src/components/gitops/GitOpsActions.tsx +1 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +1 -0
- package/src/components/gitops/ManagedResourcesList.tsx +1 -0
- package/src/components/gitops/SyncCountdown.tsx +1 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/helm/ChartBrowser.tsx +580 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +774 -0
- package/src/components/helm/HelmView.tsx +475 -0
- package/src/components/helm/InstallWizard.tsx +1060 -0
- package/src/components/helm/ManifestDiffViewer.tsx +91 -0
- package/src/components/helm/ManifestViewer.tsx +61 -0
- package/src/components/helm/OwnedResources.tsx +465 -0
- package/src/components/helm/RevisionHistory.tsx +167 -0
- package/src/components/helm/ValuesDiffPreview.tsx +190 -0
- package/src/components/helm/ValuesViewer.tsx +365 -0
- package/src/components/helm/helm-utils.ts +37 -0
- package/src/components/home/ActivitySummary.tsx +262 -0
- package/src/components/home/CertificateHealthCard.tsx +105 -0
- package/src/components/home/ClusterHealthCard.tsx +483 -0
- package/src/components/home/CostCard.tsx +112 -0
- package/src/components/home/HealthRing.tsx +1 -0
- package/src/components/home/HelmSummary.tsx +129 -0
- package/src/components/home/HomeView.tsx +224 -0
- package/src/components/home/MCPSetupDialog.tsx +417 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +109 -0
- package/src/components/home/TopologyPreview.tsx +219 -0
- package/src/components/home/TrafficSummary.tsx +154 -0
- package/src/components/logs/JsonLogLine.tsx +1 -0
- package/src/components/logs/LogCore.tsx +2 -0
- package/src/components/logs/LogsViewer.tsx +44 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +40 -0
- package/src/components/logs/useLogBuffer.ts +2 -0
- package/src/components/logs/useLogSearch.ts +1 -0
- package/src/components/portforward/PortForwardButton.tsx +375 -0
- package/src/components/portforward/PortForwardManager.tsx +871 -0
- package/src/components/resource/PrometheusCharts.tsx +687 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +214 -0
- package/src/components/resources/ImageFilesystemModal.tsx +745 -0
- package/src/components/resources/PodFilesystemModal.tsx +407 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +43 -0
- package/src/components/resources/ResourcesView.tsx +190 -0
- package/src/components/resources/drawer-components.tsx +1 -0
- package/src/components/resources/file-browser-utils.ts +35 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +1 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +17 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +1 -0
- package/src/components/resources/renderers/ChallengeRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +1 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/EventRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +1 -0
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/JobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/LeaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +44 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/OrderRenderer.tsx +1 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +1 -0
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodRenderer.tsx +94 -0
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleRenderer.tsx +1 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +1 -0
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/SbomReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +26 -0
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/VPARenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +52 -0
- package/src/components/resources/renderers/argo-cells.tsx +1 -0
- package/src/components/resources/renderers/certmanager-cells.tsx +1 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +1 -0
- package/src/components/resources/renderers/eso-cells.tsx +1 -0
- package/src/components/resources/renderers/flux-cells.tsx +1 -0
- package/src/components/resources/renderers/index.ts +91 -0
- package/src/components/resources/renderers/istio-cells.tsx +1 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +1 -0
- package/src/components/resources/renderers/keda-cells.tsx +1 -0
- package/src/components/resources/renderers/knative-cells.tsx +1 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +1 -0
- package/src/components/resources/renderers/prometheus-cells.tsx +1 -0
- package/src/components/resources/renderers/traefik-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-shared.tsx +1 -0
- package/src/components/resources/renderers/velero-cells.tsx +1 -0
- package/src/components/resources/resource-utils-argo.ts +2 -0
- package/src/components/resources/resource-utils-certmanager.ts +2 -0
- package/src/components/resources/resource-utils-cnpg.ts +2 -0
- package/src/components/resources/resource-utils-eso.ts +2 -0
- package/src/components/resources/resource-utils-flux.ts +2 -0
- package/src/components/resources/resource-utils-istio.ts +2 -0
- package/src/components/resources/resource-utils-karpenter.ts +2 -0
- package/src/components/resources/resource-utils-keda.ts +2 -0
- package/src/components/resources/resource-utils-knative.ts +2 -0
- package/src/components/resources/resource-utils-kyverno.ts +2 -0
- package/src/components/resources/resource-utils-prometheus.ts +2 -0
- package/src/components/resources/resource-utils-traefik.ts +1 -0
- package/src/components/resources/resource-utils-trivy.ts +2 -0
- package/src/components/resources/resource-utils-velero.ts +2 -0
- package/src/components/resources/resource-utils.ts +5 -0
- package/src/components/settings/SettingsDialog.tsx +537 -0
- package/src/components/shared/CreateResourceDialog.tsx +17 -0
- package/src/components/shared/EditableYamlView.tsx +24 -0
- package/src/components/shared/LargeClusterNamespacePicker.tsx +70 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +31 -0
- package/src/components/timeline/DiffViewer.tsx +1 -0
- package/src/components/timeline/TimelineList.tsx +69 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1308 -0
- package/src/components/timeline/TimelineView.tsx +157 -0
- package/src/components/timeline/shared.tsx +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +571 -0
- package/src/components/traffic/TrafficFlowList.tsx +415 -0
- package/src/components/traffic/TrafficFlowListContext.tsx +68 -0
- package/src/components/traffic/TrafficGraph.tsx +1546 -0
- package/src/components/traffic/TrafficView.tsx +1213 -0
- package/src/components/traffic/TrafficWizard.tsx +386 -0
- package/src/components/traffic/index.ts +3 -0
- package/src/components/ui/CodeViewer.tsx +8 -0
- package/src/components/ui/CommandPalette.tsx +460 -0
- package/src/components/ui/ConfirmDialog.tsx +1 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +619 -0
- package/src/components/ui/ErrorBoundary.tsx +46 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -0
- package/src/components/ui/Markdown.tsx +108 -0
- package/src/components/ui/MetricsChart.tsx +1 -0
- package/src/components/ui/NamespaceSelector.tsx +436 -0
- package/src/components/ui/ResourceBar.tsx +1 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +301 -0
- package/src/components/ui/Toast.tsx +1 -0
- package/src/components/ui/Tooltip.tsx +1 -0
- package/src/components/ui/UpdateNotification.tsx +299 -0
- package/src/components/ui/YamlEditor.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +532 -0
- package/src/context/ConnectionContext.tsx +173 -0
- package/src/context/ContextSwitchContext.tsx +56 -0
- package/src/context/NavCustomization.tsx +62 -0
- package/src/context/ThemeContext.tsx +97 -0
- package/src/contexts/CapabilitiesContext.tsx +130 -0
- package/src/hooks/useAnimatedUnmount.ts +1 -0
- package/src/hooks/useDesktopDownload.ts +41 -0
- package/src/hooks/useEventSource.ts +262 -0
- package/src/hooks/useFavorites.ts +69 -0
- package/src/hooks/useKeyboardShortcuts.tsx +7 -0
- package/src/hooks/useRefreshAnimation.ts +1 -0
- package/src/index.css +243 -0
- package/src/index.ts +17 -0
- package/src/main.tsx +158 -0
- package/src/types/gitops.ts +2 -0
- package/src/types.ts +3 -0
- package/src/utils/animation.ts +2 -0
- package/src/utils/badge-colors.ts +2 -0
- package/src/utils/context-name.ts +2 -0
- package/src/utils/desktop-download.ts +66 -0
- package/src/utils/desktop-open-folder.ts +21 -0
- package/src/utils/format.ts +2 -0
- package/src/utils/log-format.ts +12 -0
- package/src/utils/navigation.ts +23 -0
- package/src/utils/resource-hierarchy.ts +2 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.ts +2 -0
- package/src/utils/traffic-colors.ts +54 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,2583 @@
|
|
|
1
|
+
import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
|
|
2
|
+
import { showApiError, showApiSuccess } from '../components/ui/Toast'
|
|
3
|
+
import type {
|
|
4
|
+
Topology,
|
|
5
|
+
ClusterInfo,
|
|
6
|
+
Capabilities,
|
|
7
|
+
ContextInfo,
|
|
8
|
+
Namespace,
|
|
9
|
+
TimelineEvent,
|
|
10
|
+
TimeRange,
|
|
11
|
+
ResourceWithRelationships,
|
|
12
|
+
HelmRelease,
|
|
13
|
+
HelmReleaseDetail,
|
|
14
|
+
HelmValues,
|
|
15
|
+
ManifestDiff,
|
|
16
|
+
UpgradeInfo,
|
|
17
|
+
BatchUpgradeInfo,
|
|
18
|
+
ValuesPreviewResponse,
|
|
19
|
+
HelmRepository,
|
|
20
|
+
ChartSearchResult,
|
|
21
|
+
ChartDetail,
|
|
22
|
+
InstallChartRequest,
|
|
23
|
+
ArtifactHubSearchResult,
|
|
24
|
+
ArtifactHubChartDetail,
|
|
25
|
+
} from '../types'
|
|
26
|
+
import type { GitOpsOperationResponse } from '../types/gitops'
|
|
27
|
+
import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
|
|
28
|
+
|
|
29
|
+
// Wrapper around fetch that always includes credentials (for session cookies)
|
|
30
|
+
// and handles 401 responses globally. Merges caller-provided headers with
|
|
31
|
+
// auth headers from the config module so library consumers (Radar Hub) can
|
|
32
|
+
// inject Authorization bearer tokens without each call site knowing.
|
|
33
|
+
function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
34
|
+
const headers = new Headers(init?.headers)
|
|
35
|
+
for (const [k, v] of Object.entries(getAuthHeaders())) {
|
|
36
|
+
if (!headers.has(k)) headers.set(k, v)
|
|
37
|
+
}
|
|
38
|
+
return fetch(input, { credentials: getCredentialsMode(), ...init, headers }).then(async response => {
|
|
39
|
+
const authPrefix = `${getBasename()}/auth`
|
|
40
|
+
if (response.status === 401 && !window.location.pathname.startsWith(authPrefix)) {
|
|
41
|
+
// Save current location so user returns to where they were after re-auth.
|
|
42
|
+
// Editor draft is auto-saved by EditableYamlView via sessionStorage.
|
|
43
|
+
try { sessionStorage.setItem('radar_return_path', window.location.pathname + window.location.search) } catch { /* best-effort */ }
|
|
44
|
+
|
|
45
|
+
let authMode: string | undefined
|
|
46
|
+
try {
|
|
47
|
+
const body = await response.clone().json()
|
|
48
|
+
authMode = body.authMode
|
|
49
|
+
} catch {
|
|
50
|
+
console.warn('Authentication required (unable to determine auth mode)')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (authMode === 'oidc') {
|
|
54
|
+
window.location.href = routePath('/auth/login')
|
|
55
|
+
} else {
|
|
56
|
+
// Proxy mode or unknown — reload is safe for both (proxy re-injects headers,
|
|
57
|
+
// unknown avoids redirecting to /auth/login which doesn't exist in proxy mode).
|
|
58
|
+
// Guard against infinite reload if proxy is misconfigured and keeps returning 401.
|
|
59
|
+
const lastReload = sessionStorage.getItem('radar_proxy_reload')
|
|
60
|
+
const now = Date.now()
|
|
61
|
+
if (!lastReload || now - parseInt(lastReload) > 5000) {
|
|
62
|
+
try { sessionStorage.setItem('radar_proxy_reload', String(now)) } catch { /* best-effort */ }
|
|
63
|
+
window.location.reload()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return response
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ApiError preserves HTTP status code for callers to distinguish 403/404/500 etc.
|
|
72
|
+
export class ApiError extends Error {
|
|
73
|
+
status: number
|
|
74
|
+
data?: Record<string, unknown>
|
|
75
|
+
constructor(message: string, status: number, data?: Record<string, unknown>) {
|
|
76
|
+
super(message)
|
|
77
|
+
this.name = 'ApiError'
|
|
78
|
+
this.status = status
|
|
79
|
+
this.data = data
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isForbiddenError(error: unknown): boolean {
|
|
84
|
+
return error instanceof ApiError && error.status === 403
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function fetchJSON<T>(path: string): Promise<T> {
|
|
88
|
+
const response = await apiFetch(`${getApiBase()}${path}`)
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
91
|
+
throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
|
|
92
|
+
}
|
|
93
|
+
return response.json()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// Dashboard
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
export interface DashboardCluster {
|
|
101
|
+
name: string
|
|
102
|
+
platform: string
|
|
103
|
+
version: string
|
|
104
|
+
connected: boolean
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface DashboardHealth {
|
|
108
|
+
healthy: number
|
|
109
|
+
warning: number
|
|
110
|
+
error: number
|
|
111
|
+
warningEvents: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface DashboardProblem {
|
|
115
|
+
kind: string
|
|
116
|
+
namespace: string
|
|
117
|
+
name: string
|
|
118
|
+
group?: string
|
|
119
|
+
severity: 'critical' | 'high' | 'medium'
|
|
120
|
+
reason: string
|
|
121
|
+
message: string
|
|
122
|
+
age: string
|
|
123
|
+
ageSeconds: number
|
|
124
|
+
duration: string
|
|
125
|
+
durationSeconds: number
|
|
126
|
+
podCount?: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface WorkloadCount {
|
|
130
|
+
total: number
|
|
131
|
+
ready: number
|
|
132
|
+
unready: number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface DashboardMetrics {
|
|
136
|
+
cpu?: MetricSummary
|
|
137
|
+
memory?: MetricSummary
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface MetricSummary {
|
|
141
|
+
usageMillis: number
|
|
142
|
+
requestsMillis: number
|
|
143
|
+
capacityMillis: number
|
|
144
|
+
usagePercent: number
|
|
145
|
+
requestPercent: number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface DashboardResourceCounts {
|
|
149
|
+
pods: { total: number; running: number; pending: number; failed: number; succeeded: number }
|
|
150
|
+
deployments: { total: number; available: number; unavailable: number }
|
|
151
|
+
statefulSets: WorkloadCount
|
|
152
|
+
daemonSets: WorkloadCount
|
|
153
|
+
services: number
|
|
154
|
+
ingresses: number
|
|
155
|
+
gateways?: number
|
|
156
|
+
routes?: number
|
|
157
|
+
nodes: { total: number; ready: number; notReady: number; cordoned: number }
|
|
158
|
+
namespaces: number
|
|
159
|
+
jobs: { total: number; active: number; succeeded: number; failed: number }
|
|
160
|
+
cronJobs: { total: number; active: number; suspended: number }
|
|
161
|
+
configMaps: number
|
|
162
|
+
secrets: number
|
|
163
|
+
pvcs: { total: number; bound: number; pending: number; unbound: number }
|
|
164
|
+
restricted?: string[] // Resource kinds the user cannot list due to RBAC
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface DashboardEvent {
|
|
168
|
+
type: string
|
|
169
|
+
reason: string
|
|
170
|
+
message: string
|
|
171
|
+
involvedObject: string
|
|
172
|
+
namespace: string
|
|
173
|
+
timestamp: string
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface DashboardChange {
|
|
177
|
+
kind: string
|
|
178
|
+
namespace: string
|
|
179
|
+
name: string
|
|
180
|
+
changeType: string
|
|
181
|
+
summary: string
|
|
182
|
+
timestamp: string
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface DashboardTopologySummary {
|
|
186
|
+
nodeCount: number
|
|
187
|
+
edgeCount: number
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface DashboardTopFlow {
|
|
191
|
+
src: string
|
|
192
|
+
dst: string
|
|
193
|
+
requestsPerSec?: number
|
|
194
|
+
connections: number
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface DashboardTrafficSummary {
|
|
198
|
+
source: string
|
|
199
|
+
flowCount: number
|
|
200
|
+
topFlows: DashboardTopFlow[]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface DashboardHelmRelease {
|
|
204
|
+
name: string
|
|
205
|
+
namespace: string
|
|
206
|
+
chart: string
|
|
207
|
+
chartVersion: string
|
|
208
|
+
status: string
|
|
209
|
+
resourceHealth?: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface DashboardHelmSummary {
|
|
213
|
+
total: number
|
|
214
|
+
releases: DashboardHelmRelease[]
|
|
215
|
+
restricted?: boolean // True when user lacks permissions to list Helm releases
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface DashboardCRDCount {
|
|
219
|
+
kind: string
|
|
220
|
+
name: string
|
|
221
|
+
group: string
|
|
222
|
+
count: number
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Re-export shared types from k8s-ui — single source of truth
|
|
226
|
+
import type { AuditCardData, AuditFinding, ResourceGroup, CheckMeta } from '@skyhook-io/k8s-ui'
|
|
227
|
+
export type DashboardAudit = AuditCardData
|
|
228
|
+
export type { AuditFinding, ResourceGroup, CheckMeta }
|
|
229
|
+
|
|
230
|
+
export interface AuditResponse {
|
|
231
|
+
summary: DashboardAudit
|
|
232
|
+
findings: AuditFinding[]
|
|
233
|
+
groups: ResourceGroup[]
|
|
234
|
+
checks: Record<string, CheckMeta>
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface DashboardCertificateHealth {
|
|
238
|
+
total: number
|
|
239
|
+
healthy: number
|
|
240
|
+
warning: number
|
|
241
|
+
critical: number
|
|
242
|
+
expired: number
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface DashboardNetworkPolicyCoverage {
|
|
246
|
+
totalPolicies: number
|
|
247
|
+
coveredWorkloads: number
|
|
248
|
+
totalWorkloads: number
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface DashboardResponse {
|
|
252
|
+
cluster: DashboardCluster
|
|
253
|
+
health: DashboardHealth
|
|
254
|
+
problems: DashboardProblem[]
|
|
255
|
+
resourceCounts: DashboardResourceCounts
|
|
256
|
+
recentEvents: DashboardEvent[]
|
|
257
|
+
recentChanges: DashboardChange[]
|
|
258
|
+
topologySummary: DashboardTopologySummary
|
|
259
|
+
trafficSummary: DashboardTrafficSummary | null
|
|
260
|
+
metrics: DashboardMetrics | null
|
|
261
|
+
metricsServerAvailable: boolean
|
|
262
|
+
certificateHealth: DashboardCertificateHealth | null
|
|
263
|
+
networkPolicyCoverage: DashboardNetworkPolicyCoverage | null
|
|
264
|
+
audit: DashboardAudit | null
|
|
265
|
+
nodeVersionSkew: { versions: Record<string, string[]>; minVersion: string; maxVersion: string } | null
|
|
266
|
+
deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
|
|
267
|
+
accessRestricted?: boolean // True when user has no namespace access (RBAC)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface DashboardCRDsResponse {
|
|
271
|
+
topCRDs: DashboardCRDCount[]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function useDashboard(namespaces: string[] = []) {
|
|
275
|
+
const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
|
|
276
|
+
return useQuery<DashboardResponse>({
|
|
277
|
+
queryKey: ['dashboard', namespaces],
|
|
278
|
+
queryFn: () => fetchJSON(`/dashboard${params}`),
|
|
279
|
+
staleTime: 15000, // 15 seconds
|
|
280
|
+
refetchInterval: 30000, // Refresh every 30 seconds
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Best practices
|
|
285
|
+
export function useAudit(namespaces: string[] = []) {
|
|
286
|
+
const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
|
|
287
|
+
return useQuery<AuditResponse>({
|
|
288
|
+
queryKey: ['audit', namespaces],
|
|
289
|
+
queryFn: () => fetchJSON(`/audit${params}`),
|
|
290
|
+
staleTime: 30000,
|
|
291
|
+
refetchInterval: 60000,
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function useResourceAudit(kind: string, namespace: string, name: string) {
|
|
296
|
+
return useQuery<AuditFinding[]>({
|
|
297
|
+
queryKey: ['audit', 'resource', kind, namespace, name],
|
|
298
|
+
queryFn: () => fetchJSON(`/audit/resource/${kind}/${namespace}/${name}`),
|
|
299
|
+
staleTime: 30000,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Audit settings
|
|
304
|
+
export interface AuditSettings {
|
|
305
|
+
ignoredNamespaces: string[]
|
|
306
|
+
disabledChecks: string[]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function useAuditSettings() {
|
|
310
|
+
return useQuery<AuditSettings>({
|
|
311
|
+
queryKey: ['audit-settings'],
|
|
312
|
+
queryFn: () => fetchJSON('/settings/audit'),
|
|
313
|
+
staleTime: 60000,
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function useUpdateAuditSettings() {
|
|
318
|
+
const queryClient = useQueryClient()
|
|
319
|
+
return useMutation({
|
|
320
|
+
mutationFn: async (settings: AuditSettings) => {
|
|
321
|
+
const resp = await apiFetch(`${getApiBase()}/settings/audit`, {
|
|
322
|
+
method: 'PUT',
|
|
323
|
+
headers: { 'Content-Type': 'application/json' },
|
|
324
|
+
body: JSON.stringify(settings),
|
|
325
|
+
})
|
|
326
|
+
if (!resp.ok) {
|
|
327
|
+
const body = await resp.json().catch(() => ({ error: 'Unknown error' }))
|
|
328
|
+
throw new Error(body.error || `HTTP ${resp.status}`)
|
|
329
|
+
}
|
|
330
|
+
return resp.json()
|
|
331
|
+
},
|
|
332
|
+
meta: {
|
|
333
|
+
errorMessage: 'Failed to save audit settings',
|
|
334
|
+
successMessage: 'Audit settings saved',
|
|
335
|
+
},
|
|
336
|
+
onSuccess: () => {
|
|
337
|
+
queryClient.invalidateQueries({ queryKey: ['audit-settings'] })
|
|
338
|
+
queryClient.invalidateQueries({ queryKey: ['audit'] })
|
|
339
|
+
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
|
340
|
+
},
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Certificate expiry for TLS secrets (used in secrets list view)
|
|
345
|
+
export interface CertExpiry {
|
|
346
|
+
daysLeft: number
|
|
347
|
+
expired?: boolean
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function useSecretCertExpiry(namespaces: string[] = [], enabled = true) {
|
|
351
|
+
const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
|
|
352
|
+
return useQuery<Record<string, CertExpiry>>({
|
|
353
|
+
queryKey: ['secret-cert-expiry', namespaces],
|
|
354
|
+
queryFn: () => fetchJSON(`/secrets/certificate-expiry${params}`),
|
|
355
|
+
enabled,
|
|
356
|
+
staleTime: 30000,
|
|
357
|
+
refetchInterval: 60000,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// CRD counts - loaded lazily after main dashboard
|
|
362
|
+
export function useDashboardCRDs(namespaces: string[] = []) {
|
|
363
|
+
const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
|
|
364
|
+
return useQuery<DashboardCRDsResponse>({
|
|
365
|
+
queryKey: ['dashboard-crds', namespaces],
|
|
366
|
+
queryFn: () => fetchJSON(`/dashboard/crds${params}`),
|
|
367
|
+
staleTime: 30000, // 30 seconds - less frequent updates
|
|
368
|
+
refetchInterval: 60000, // Refresh every minute
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Helm summary - loaded lazily after main dashboard (Helm SDK lists K8s secrets, ~2-3s)
|
|
373
|
+
export function useDashboardHelm(namespaces: string[] = []) {
|
|
374
|
+
const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
|
|
375
|
+
return useQuery<DashboardHelmSummary>({
|
|
376
|
+
queryKey: ['dashboard-helm', namespaces],
|
|
377
|
+
queryFn: () => fetchJSON(`/dashboard/helm${params}`),
|
|
378
|
+
staleTime: 30000,
|
|
379
|
+
refetchInterval: 60000,
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ============================================================================
|
|
384
|
+
// OpenCost
|
|
385
|
+
// ============================================================================
|
|
386
|
+
|
|
387
|
+
export interface OpenCostNamespaceCost {
|
|
388
|
+
name: string
|
|
389
|
+
hourlyCost: number
|
|
390
|
+
cpuCost: number
|
|
391
|
+
memoryCost: number
|
|
392
|
+
storageCost?: number
|
|
393
|
+
cpuUsageCost?: number
|
|
394
|
+
memoryUsageCost?: number
|
|
395
|
+
efficiency?: number
|
|
396
|
+
idleCost?: number
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export type CostUnavailableReason = 'no_prometheus' | 'no_metrics' | 'query_error'
|
|
400
|
+
|
|
401
|
+
export interface OpenCostSummary {
|
|
402
|
+
available: boolean
|
|
403
|
+
reason?: CostUnavailableReason
|
|
404
|
+
currency?: string
|
|
405
|
+
window?: string
|
|
406
|
+
totalHourlyCost?: number
|
|
407
|
+
totalStorageCost?: number
|
|
408
|
+
totalIdleCost?: number
|
|
409
|
+
clusterEfficiency?: number
|
|
410
|
+
namespaces?: OpenCostNamespaceCost[]
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function useOpenCostSummary() {
|
|
414
|
+
return useQuery<OpenCostSummary>({
|
|
415
|
+
queryKey: ['opencost-summary'],
|
|
416
|
+
queryFn: () => fetchJSON('/opencost/summary'),
|
|
417
|
+
refetchInterval: 60000, // Refresh every minute
|
|
418
|
+
staleTime: 30000,
|
|
419
|
+
placeholderData: (prev) => prev, // Keep previous data visible during refetch
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Workload-level cost breakdown for a namespace
|
|
424
|
+
export interface OpenCostWorkloadCost {
|
|
425
|
+
name: string
|
|
426
|
+
kind: string
|
|
427
|
+
hourlyCost: number
|
|
428
|
+
cpuCost: number
|
|
429
|
+
memoryCost: number
|
|
430
|
+
replicas: number
|
|
431
|
+
cpuUsageCost?: number
|
|
432
|
+
memoryUsageCost?: number
|
|
433
|
+
efficiency?: number
|
|
434
|
+
idleCost?: number
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export interface OpenCostWorkloadResponse {
|
|
438
|
+
available: boolean
|
|
439
|
+
reason?: CostUnavailableReason
|
|
440
|
+
namespace: string
|
|
441
|
+
workloads: OpenCostWorkloadCost[]
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function useOpenCostWorkloads(namespace: string, options?: { enabled?: boolean }) {
|
|
445
|
+
return useQuery<OpenCostWorkloadResponse>({
|
|
446
|
+
queryKey: ['opencost-workloads', namespace],
|
|
447
|
+
queryFn: () => fetchJSON(`/opencost/workloads?namespace=${encodeURIComponent(namespace)}`),
|
|
448
|
+
enabled: (options?.enabled ?? true) && Boolean(namespace),
|
|
449
|
+
staleTime: 30000,
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Cost trend over time
|
|
454
|
+
export type CostTimeRange = '6h' | '24h' | '7d'
|
|
455
|
+
|
|
456
|
+
export interface OpenCostTrendDataPoint {
|
|
457
|
+
timestamp: number
|
|
458
|
+
value: number
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export interface OpenCostTrendSeries {
|
|
462
|
+
namespace: string
|
|
463
|
+
dataPoints: OpenCostTrendDataPoint[]
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export interface OpenCostTrendResponse {
|
|
467
|
+
available: boolean
|
|
468
|
+
reason?: CostUnavailableReason
|
|
469
|
+
range: string
|
|
470
|
+
series?: OpenCostTrendSeries[]
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function useOpenCostTrend(range_: CostTimeRange = '24h') {
|
|
474
|
+
return useQuery<OpenCostTrendResponse>({
|
|
475
|
+
queryKey: ['opencost-trend', range_],
|
|
476
|
+
queryFn: () => fetchJSON(`/opencost/trend?range=${range_}`),
|
|
477
|
+
staleTime: 60000,
|
|
478
|
+
refetchInterval: 120000, // Refresh every 2 minutes
|
|
479
|
+
placeholderData: (prev) => prev,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Node cost breakdown
|
|
484
|
+
export interface OpenCostNodeCost {
|
|
485
|
+
name: string
|
|
486
|
+
instanceType?: string
|
|
487
|
+
region?: string
|
|
488
|
+
hourlyCost: number
|
|
489
|
+
cpuCost: number
|
|
490
|
+
memoryCost: number
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export interface OpenCostNodeResponse {
|
|
494
|
+
available: boolean
|
|
495
|
+
reason?: CostUnavailableReason
|
|
496
|
+
nodes?: OpenCostNodeCost[]
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function useOpenCostNodes() {
|
|
500
|
+
return useQuery<OpenCostNodeResponse>({
|
|
501
|
+
queryKey: ['opencost-nodes'],
|
|
502
|
+
queryFn: () => fetchJSON('/opencost/nodes'),
|
|
503
|
+
staleTime: 60000,
|
|
504
|
+
refetchInterval: 120000,
|
|
505
|
+
placeholderData: (prev) => prev,
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Cluster info
|
|
510
|
+
export function useClusterInfo() {
|
|
511
|
+
const query = useQuery<ClusterInfo>({
|
|
512
|
+
queryKey: ['cluster-info'],
|
|
513
|
+
queryFn: () => fetchJSON('/cluster-info'),
|
|
514
|
+
staleTime: 60000, // 1 minute
|
|
515
|
+
// Poll faster when CRD discovery is in progress
|
|
516
|
+
refetchInterval: (query) => {
|
|
517
|
+
const status = query.state.data?.crdDiscoveryStatus
|
|
518
|
+
return status === 'discovering' ? 2000 : false
|
|
519
|
+
},
|
|
520
|
+
})
|
|
521
|
+
return query
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Version check
|
|
525
|
+
export type InstallMethod = 'homebrew' | 'krew' | 'scoop' | 'direct' | 'desktop'
|
|
526
|
+
|
|
527
|
+
export interface VersionInfo {
|
|
528
|
+
currentVersion: string
|
|
529
|
+
latestVersion?: string
|
|
530
|
+
updateAvailable: boolean
|
|
531
|
+
releaseUrl?: string
|
|
532
|
+
releaseNotes?: string
|
|
533
|
+
installMethod: InstallMethod
|
|
534
|
+
updateCommand?: string
|
|
535
|
+
error?: string
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function useVersionCheck() {
|
|
539
|
+
return useQuery<VersionInfo>({
|
|
540
|
+
queryKey: ['version-check'],
|
|
541
|
+
queryFn: () => fetchJSON('/version-check'),
|
|
542
|
+
staleTime: 60 * 60 * 1000, // 1 hour
|
|
543
|
+
retry: false, // Don't retry on failure
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ============================================================================
|
|
548
|
+
// Desktop Update API hooks
|
|
549
|
+
// ============================================================================
|
|
550
|
+
|
|
551
|
+
export type DesktopUpdateState = 'idle' | 'downloading' | 'ready' | 'applying' | 'error'
|
|
552
|
+
|
|
553
|
+
export interface DesktopUpdateStatus {
|
|
554
|
+
state: DesktopUpdateState
|
|
555
|
+
progress?: number // 0.0 - 1.0 during download
|
|
556
|
+
version?: string
|
|
557
|
+
error?: string
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function useStartDesktopUpdate() {
|
|
561
|
+
return useMutation({
|
|
562
|
+
mutationFn: async () => {
|
|
563
|
+
const response = await apiFetch(`${getApiBase()}/desktop/update`, {
|
|
564
|
+
method: 'POST',
|
|
565
|
+
})
|
|
566
|
+
if (!response.ok) {
|
|
567
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
568
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
569
|
+
}
|
|
570
|
+
return response.json()
|
|
571
|
+
},
|
|
572
|
+
meta: {
|
|
573
|
+
errorMessage: 'Failed to start update',
|
|
574
|
+
},
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function useDesktopUpdateStatus(enabled: boolean) {
|
|
579
|
+
return useQuery<DesktopUpdateStatus>({
|
|
580
|
+
queryKey: ['desktop-update-status'],
|
|
581
|
+
queryFn: () => fetchJSON('/desktop/update/status'),
|
|
582
|
+
enabled,
|
|
583
|
+
refetchInterval: 500, // Poll every 500ms during active update
|
|
584
|
+
staleTime: 0, // Always refetch
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function useApplyDesktopUpdate() {
|
|
589
|
+
return useMutation({
|
|
590
|
+
mutationFn: async () => {
|
|
591
|
+
const response = await apiFetch(`${getApiBase()}/desktop/update/apply`, {
|
|
592
|
+
method: 'POST',
|
|
593
|
+
})
|
|
594
|
+
if (!response.ok) {
|
|
595
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
596
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
597
|
+
}
|
|
598
|
+
return response.json()
|
|
599
|
+
},
|
|
600
|
+
meta: {
|
|
601
|
+
errorMessage: 'Failed to apply update',
|
|
602
|
+
successMessage: 'Update applied — restarting...',
|
|
603
|
+
},
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Runtime stats for debug overlay
|
|
608
|
+
export interface RuntimeStats {
|
|
609
|
+
heapMB: number
|
|
610
|
+
heapObjectsK: number
|
|
611
|
+
goroutines: number
|
|
612
|
+
uptimeSeconds: number
|
|
613
|
+
typedInformers?: number
|
|
614
|
+
dynamicInformers?: number
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export interface HealthResponse {
|
|
618
|
+
status: string
|
|
619
|
+
resourceCount: number
|
|
620
|
+
runtime: RuntimeStats
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function useRuntimeStats(enabled: boolean = true) {
|
|
624
|
+
return useQuery<HealthResponse>({
|
|
625
|
+
queryKey: ['health'],
|
|
626
|
+
queryFn: () => fetchJSON('/health'),
|
|
627
|
+
staleTime: 2000, // 2 seconds
|
|
628
|
+
refetchInterval: enabled ? 3000 : false, // Refresh every 3 seconds when enabled
|
|
629
|
+
enabled,
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Capabilities (RBAC-based feature flags)
|
|
634
|
+
export function useCapabilities() {
|
|
635
|
+
return useQuery<Capabilities>({
|
|
636
|
+
queryKey: ['capabilities'],
|
|
637
|
+
queryFn: () => fetchJSON('/capabilities'),
|
|
638
|
+
staleTime: 60000, // 1 minute - cached on backend too
|
|
639
|
+
refetchInterval: 60000, // Re-check periodically so transient failures self-correct
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Namespace-scoped capabilities: lazy re-check for exec/logs/portForward when
|
|
644
|
+
// global RBAC checks denied them. Users with namespace-scoped RoleBindings may
|
|
645
|
+
// have these permissions in specific namespaces.
|
|
646
|
+
export function useNamespaceCapabilities(namespace: string | undefined, globalCaps: Capabilities) {
|
|
647
|
+
const needsCheck = namespace && (!globalCaps.exec || !globalCaps.logs || !globalCaps.portForward)
|
|
648
|
+
return useQuery<Capabilities>({
|
|
649
|
+
queryKey: ['capabilities', namespace],
|
|
650
|
+
queryFn: () => fetchJSON(`/capabilities?namespace=${encodeURIComponent(namespace!)}`),
|
|
651
|
+
enabled: !!needsCheck,
|
|
652
|
+
staleTime: 60000,
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Auth
|
|
657
|
+
export interface AuthMe {
|
|
658
|
+
authEnabled: boolean
|
|
659
|
+
authMode?: string
|
|
660
|
+
username?: string
|
|
661
|
+
groups?: string[]
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export function useAuthMe() {
|
|
665
|
+
return useQuery<AuthMe>({
|
|
666
|
+
queryKey: ['auth-me'],
|
|
667
|
+
queryFn: () => fetchJSON('/auth/me'),
|
|
668
|
+
staleTime: 300000, // 5 minutes
|
|
669
|
+
})
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Namespaces
|
|
673
|
+
export function useNamespaces() {
|
|
674
|
+
return useQuery<Namespace[]>({
|
|
675
|
+
queryKey: ['namespaces'],
|
|
676
|
+
queryFn: () => fetchJSON('/namespaces'),
|
|
677
|
+
staleTime: 30000, // 30 seconds
|
|
678
|
+
})
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Topology (for manual refresh)
|
|
682
|
+
export function useTopology(namespaces: string[], viewMode: string = 'resources', options?: { enabled?: boolean }) {
|
|
683
|
+
const params = new URLSearchParams()
|
|
684
|
+
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
685
|
+
if (viewMode) params.set('view', viewMode)
|
|
686
|
+
const queryString = params.toString()
|
|
687
|
+
|
|
688
|
+
return useQuery<Topology>({
|
|
689
|
+
queryKey: ['topology', namespaces, viewMode],
|
|
690
|
+
queryFn: () => fetchJSON(`/topology${queryString ? `?${queryString}` : ''}`),
|
|
691
|
+
staleTime: 5000, // 5 seconds
|
|
692
|
+
enabled: options?.enabled !== false,
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Generic resource fetching - returns resource with relationships
|
|
697
|
+
// Uses '_' as placeholder for cluster-scoped resources (empty namespace)
|
|
698
|
+
export function useResource<T>(kind: string, namespace: string, name: string, group?: string) {
|
|
699
|
+
// For cluster-scoped resources, use '_' as namespace placeholder
|
|
700
|
+
const ns = namespace || '_'
|
|
701
|
+
const params = new URLSearchParams()
|
|
702
|
+
if (group) params.set('group', group)
|
|
703
|
+
const queryString = params.toString()
|
|
704
|
+
|
|
705
|
+
const query = useQuery<ResourceWithRelationships<T>>({
|
|
706
|
+
queryKey: ['resource', kind, namespace, name, group],
|
|
707
|
+
queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
|
|
708
|
+
enabled: Boolean(kind && name), // namespace can be empty for cluster-scoped resources
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
// Extract resource and relationships from the response
|
|
712
|
+
return {
|
|
713
|
+
...query,
|
|
714
|
+
data: query.data?.resource,
|
|
715
|
+
relationships: query.data?.relationships,
|
|
716
|
+
certificateInfo: query.data?.certificateInfo,
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Hook that returns full response with relationships explicitly
|
|
721
|
+
export function useResourceWithRelationships<T>(kind: string, namespace: string, name: string, group?: string) {
|
|
722
|
+
const ns = namespace || '_'
|
|
723
|
+
const params = new URLSearchParams()
|
|
724
|
+
if (group) params.set('group', group)
|
|
725
|
+
const queryString = params.toString()
|
|
726
|
+
|
|
727
|
+
return useQuery<ResourceWithRelationships<T>>({
|
|
728
|
+
queryKey: ['resource', kind, namespace, name, group],
|
|
729
|
+
queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
|
|
730
|
+
enabled: Boolean(kind && name),
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// List resources - queryKey includes group for cache sharing with ResourcesView
|
|
735
|
+
export function useResources<T>(kind: string, namespace?: string, group?: string) {
|
|
736
|
+
const params = new URLSearchParams()
|
|
737
|
+
if (namespace) params.set('namespace', namespace)
|
|
738
|
+
if (group) params.set('group', group)
|
|
739
|
+
const queryString = params.toString()
|
|
740
|
+
|
|
741
|
+
return useQuery<T[]>({
|
|
742
|
+
queryKey: ['resources', kind, group, namespace],
|
|
743
|
+
queryFn: () => fetchJSON(`/resources/${kind}${queryString ? `?${queryString}` : ''}`),
|
|
744
|
+
staleTime: 30000, // 30 seconds - matches refetchInterval in ResourcesView
|
|
745
|
+
})
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Timeline changes (unified view of changes + K8s events)
|
|
749
|
+
export interface UseChangesOptions {
|
|
750
|
+
namespaces?: string[]
|
|
751
|
+
kind?: string
|
|
752
|
+
timeRange?: TimeRange
|
|
753
|
+
filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
|
|
754
|
+
includeK8sEvents?: boolean
|
|
755
|
+
includeManaged?: boolean
|
|
756
|
+
limit?: number
|
|
757
|
+
enabled?: boolean
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function getTimeRangeDate(range: TimeRange): Date | null {
|
|
761
|
+
if (range === 'all') return null
|
|
762
|
+
const now = new Date()
|
|
763
|
+
switch (range) {
|
|
764
|
+
case '5m':
|
|
765
|
+
return new Date(now.getTime() - 5 * 60 * 1000)
|
|
766
|
+
case '30m':
|
|
767
|
+
return new Date(now.getTime() - 30 * 60 * 1000)
|
|
768
|
+
case '1h':
|
|
769
|
+
return new Date(now.getTime() - 60 * 60 * 1000)
|
|
770
|
+
case '6h':
|
|
771
|
+
return new Date(now.getTime() - 6 * 60 * 60 * 1000)
|
|
772
|
+
case '24h':
|
|
773
|
+
return new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
|
774
|
+
default:
|
|
775
|
+
return null
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export function useChanges(options: UseChangesOptions = {}) {
|
|
780
|
+
const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, limit = 200, enabled = true } = options
|
|
781
|
+
|
|
782
|
+
const params = new URLSearchParams()
|
|
783
|
+
if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
|
|
784
|
+
if (kind) params.set('kind', kind)
|
|
785
|
+
if (filter) params.set('filter', filter)
|
|
786
|
+
if (!includeK8sEvents) params.set('include_k8s_events', 'false')
|
|
787
|
+
if (includeManaged) params.set('include_managed', 'true')
|
|
788
|
+
params.set('limit', String(limit))
|
|
789
|
+
|
|
790
|
+
const sinceDate = getTimeRangeDate(timeRange)
|
|
791
|
+
if (sinceDate) {
|
|
792
|
+
params.set('since', sinceDate.toISOString())
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const queryString = params.toString()
|
|
796
|
+
|
|
797
|
+
return useQuery<TimelineEvent[]>({
|
|
798
|
+
queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, limit],
|
|
799
|
+
queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
|
|
800
|
+
staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
|
|
801
|
+
refetchInterval: 60000, // SSE handles real-time updates; this is a fallback
|
|
802
|
+
enabled,
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Children changes for a parent workload (e.g., ReplicaSets and Pods under a Deployment)
|
|
807
|
+
export function useResourceChildren(kind: string, namespace: string, name: string, timeRange: TimeRange = '1h') {
|
|
808
|
+
const sinceDate = getTimeRangeDate(timeRange)
|
|
809
|
+
const params = new URLSearchParams()
|
|
810
|
+
if (sinceDate) {
|
|
811
|
+
params.set('since', sinceDate.toISOString())
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return useQuery<TimelineEvent[]>({
|
|
815
|
+
queryKey: ['resource-children', kind, namespace, name, timeRange],
|
|
816
|
+
queryFn: () => fetchJSON(`/changes/${kind}/${namespace}/${name}/children?${params.toString()}`),
|
|
817
|
+
enabled: Boolean(kind && namespace && name),
|
|
818
|
+
refetchInterval: 15000, // Refresh every 15 seconds
|
|
819
|
+
})
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Resource-specific events (filtered by resource name)
|
|
823
|
+
export function useResourceEvents(kind: string, namespace: string, name: string) {
|
|
824
|
+
const params = new URLSearchParams()
|
|
825
|
+
params.set('namespace', namespace)
|
|
826
|
+
params.set('kind', kind)
|
|
827
|
+
params.set('limit', '50')
|
|
828
|
+
|
|
829
|
+
// Get events from last 24 hours
|
|
830
|
+
const since = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
|
831
|
+
params.set('since', since.toISOString())
|
|
832
|
+
|
|
833
|
+
return useQuery<TimelineEvent[]>({
|
|
834
|
+
queryKey: ['resource-events', kind, namespace, name],
|
|
835
|
+
queryFn: async () => {
|
|
836
|
+
const events = await fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
|
|
837
|
+
// Filter to only events for this specific resource
|
|
838
|
+
return events.filter(e => e.name === name)
|
|
839
|
+
},
|
|
840
|
+
enabled: Boolean(kind && namespace && name),
|
|
841
|
+
refetchInterval: 15000, // Refresh every 15 seconds
|
|
842
|
+
})
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// ============================================================================
|
|
846
|
+
// Metrics (from metrics.k8s.io)
|
|
847
|
+
// ============================================================================
|
|
848
|
+
|
|
849
|
+
export interface ContainerMetrics {
|
|
850
|
+
name: string
|
|
851
|
+
usage: {
|
|
852
|
+
cpu: string // e.g., "10m" (millicores)
|
|
853
|
+
memory: string // e.g., "128Mi"
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export interface PodMetrics {
|
|
858
|
+
metadata: {
|
|
859
|
+
name: string
|
|
860
|
+
namespace: string
|
|
861
|
+
creationTimestamp: string
|
|
862
|
+
}
|
|
863
|
+
timestamp: string
|
|
864
|
+
window: string
|
|
865
|
+
containers: ContainerMetrics[]
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export interface NodeMetrics {
|
|
869
|
+
metadata: {
|
|
870
|
+
name: string
|
|
871
|
+
creationTimestamp: string
|
|
872
|
+
}
|
|
873
|
+
timestamp: string
|
|
874
|
+
window: string
|
|
875
|
+
usage: {
|
|
876
|
+
cpu: string
|
|
877
|
+
memory: string
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Fetch metrics for a specific pod
|
|
882
|
+
export function usePodMetrics(namespace: string, podName: string) {
|
|
883
|
+
return useQuery<PodMetrics>({
|
|
884
|
+
queryKey: ['pod-metrics', namespace, podName],
|
|
885
|
+
queryFn: () => fetchJSON(`/metrics/pods/${namespace}/${podName}`),
|
|
886
|
+
enabled: Boolean(namespace && podName),
|
|
887
|
+
staleTime: 15000, // Metrics are fresh for 15 seconds
|
|
888
|
+
refetchInterval: 30000, // Refresh every 30 seconds
|
|
889
|
+
})
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Fetch metrics for a specific node
|
|
893
|
+
export function useNodeMetrics(nodeName: string) {
|
|
894
|
+
return useQuery<NodeMetrics>({
|
|
895
|
+
queryKey: ['node-metrics', nodeName],
|
|
896
|
+
queryFn: () => fetchJSON(`/metrics/nodes/${nodeName}`),
|
|
897
|
+
enabled: Boolean(nodeName),
|
|
898
|
+
staleTime: 15000,
|
|
899
|
+
refetchInterval: 30000,
|
|
900
|
+
})
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// ============================================================================
|
|
904
|
+
// Metrics History (local collection)
|
|
905
|
+
// ============================================================================
|
|
906
|
+
|
|
907
|
+
export interface MetricsDataPoint {
|
|
908
|
+
timestamp: string
|
|
909
|
+
cpu: number // CPU in nanocores
|
|
910
|
+
memory: number // Memory in bytes
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export interface ContainerMetricsHistory {
|
|
914
|
+
name: string
|
|
915
|
+
dataPoints: MetricsDataPoint[]
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
export interface PodMetricsHistory {
|
|
919
|
+
namespace: string
|
|
920
|
+
name: string
|
|
921
|
+
containers: ContainerMetricsHistory[]
|
|
922
|
+
collectionError?: string
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export interface NodeMetricsHistory {
|
|
926
|
+
name: string
|
|
927
|
+
dataPoints: MetricsDataPoint[]
|
|
928
|
+
collectionError?: string
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Fetch historical metrics for a pod (last ~1 hour)
|
|
932
|
+
export function usePodMetricsHistory(namespace: string, podName: string) {
|
|
933
|
+
return useQuery<PodMetricsHistory>({
|
|
934
|
+
queryKey: ['pod-metrics-history', namespace, podName],
|
|
935
|
+
queryFn: () => fetchJSON(`/metrics/pods/${namespace}/${podName}/history`),
|
|
936
|
+
enabled: Boolean(namespace && podName),
|
|
937
|
+
staleTime: 25000, // Slightly less than poll interval
|
|
938
|
+
refetchInterval: 30000, // Match the backend poll interval
|
|
939
|
+
})
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Fetch historical metrics for a node (last ~1 hour)
|
|
943
|
+
export function useNodeMetricsHistory(nodeName: string) {
|
|
944
|
+
return useQuery<NodeMetricsHistory>({
|
|
945
|
+
queryKey: ['node-metrics-history', nodeName],
|
|
946
|
+
queryFn: () => fetchJSON(`/metrics/nodes/${nodeName}/history`),
|
|
947
|
+
enabled: Boolean(nodeName),
|
|
948
|
+
staleTime: 25000,
|
|
949
|
+
refetchInterval: 30000,
|
|
950
|
+
})
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Top metrics types (bulk, for resource table view)
|
|
954
|
+
export interface TopPodMetrics {
|
|
955
|
+
namespace: string
|
|
956
|
+
name: string
|
|
957
|
+
cpu: number // nanocores (usage)
|
|
958
|
+
memory: number // bytes (usage)
|
|
959
|
+
cpuRequest: number // nanocores (sum across containers)
|
|
960
|
+
cpuLimit: number // nanocores (sum across containers)
|
|
961
|
+
memoryRequest: number // bytes (sum across containers)
|
|
962
|
+
memoryLimit: number // bytes (sum across containers)
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
export interface TopNodeMetrics {
|
|
966
|
+
name: string
|
|
967
|
+
cpu: number // nanocores (usage)
|
|
968
|
+
memory: number // bytes (usage)
|
|
969
|
+
podCount: number // pods scheduled on this node
|
|
970
|
+
cpuAllocatable: number // nanocores
|
|
971
|
+
memoryAllocatable: number // bytes
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Fetch bulk metrics for all pods (for CPU/Memory columns in resource table)
|
|
975
|
+
export function useTopPodMetrics() {
|
|
976
|
+
return useQuery<TopPodMetrics[]>({
|
|
977
|
+
queryKey: ['top-pod-metrics'],
|
|
978
|
+
queryFn: () => fetchJSON('/metrics/top/pods'),
|
|
979
|
+
staleTime: 25000,
|
|
980
|
+
refetchInterval: 30000,
|
|
981
|
+
})
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Fetch bulk metrics for all nodes (for CPU/Memory columns in resource table)
|
|
985
|
+
export function useTopNodeMetrics() {
|
|
986
|
+
return useQuery<TopNodeMetrics[]>({
|
|
987
|
+
queryKey: ['top-node-metrics'],
|
|
988
|
+
queryFn: () => fetchJSON('/metrics/top/nodes'),
|
|
989
|
+
staleTime: 25000,
|
|
990
|
+
refetchInterval: 30000,
|
|
991
|
+
})
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ============================================================================
|
|
995
|
+
// Prometheus Metrics
|
|
996
|
+
// ============================================================================
|
|
997
|
+
|
|
998
|
+
// Prometheus types
|
|
999
|
+
export interface PrometheusStatus {
|
|
1000
|
+
available: boolean
|
|
1001
|
+
connected: boolean
|
|
1002
|
+
address?: string
|
|
1003
|
+
service?: {
|
|
1004
|
+
namespace: string
|
|
1005
|
+
name: string
|
|
1006
|
+
port: number
|
|
1007
|
+
basePath?: string
|
|
1008
|
+
}
|
|
1009
|
+
contextName?: string
|
|
1010
|
+
error?: string
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
export interface PrometheusDataPoint {
|
|
1014
|
+
timestamp: number
|
|
1015
|
+
value: number
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
export interface PrometheusSeries {
|
|
1019
|
+
labels: Record<string, string>
|
|
1020
|
+
dataPoints: PrometheusDataPoint[]
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export interface PrometheusQueryResult {
|
|
1024
|
+
resultType: string
|
|
1025
|
+
series: PrometheusSeries[]
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export interface PrometheusResourceMetrics {
|
|
1029
|
+
kind: string
|
|
1030
|
+
namespace?: string
|
|
1031
|
+
name: string
|
|
1032
|
+
category: string
|
|
1033
|
+
unit: string
|
|
1034
|
+
range: string
|
|
1035
|
+
result: PrometheusQueryResult
|
|
1036
|
+
query?: string // PromQL query (included when result is empty, for diagnostics)
|
|
1037
|
+
hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem'
|
|
1041
|
+
export type PrometheusTimeRange = '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
|
|
1042
|
+
|
|
1043
|
+
// Check Prometheus availability
|
|
1044
|
+
export function usePrometheusStatus() {
|
|
1045
|
+
return useQuery<PrometheusStatus>({
|
|
1046
|
+
queryKey: ['prometheus-status'],
|
|
1047
|
+
queryFn: () => fetchJSON('/prometheus/status'),
|
|
1048
|
+
staleTime: 30000,
|
|
1049
|
+
refetchInterval: 60000,
|
|
1050
|
+
})
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Connect to Prometheus (trigger discovery)
|
|
1054
|
+
export function usePrometheusConnect() {
|
|
1055
|
+
const queryClient = useQueryClient()
|
|
1056
|
+
return useMutation({
|
|
1057
|
+
mutationFn: async () => {
|
|
1058
|
+
const resp = await apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
|
|
1059
|
+
if (!resp.ok) {
|
|
1060
|
+
const body = await resp.json().catch(() => ({ error: 'Unknown error' }))
|
|
1061
|
+
throw new Error(body.error || `HTTP ${resp.status}`)
|
|
1062
|
+
}
|
|
1063
|
+
return resp.json() as Promise<PrometheusStatus>
|
|
1064
|
+
},
|
|
1065
|
+
onSuccess: () => {
|
|
1066
|
+
queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
|
|
1067
|
+
},
|
|
1068
|
+
meta: {
|
|
1069
|
+
errorMessage: 'Failed to connect to Prometheus',
|
|
1070
|
+
successMessage: 'Connected to Prometheus',
|
|
1071
|
+
},
|
|
1072
|
+
})
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Fetch Prometheus metrics for a resource
|
|
1076
|
+
export function usePrometheusResourceMetrics(
|
|
1077
|
+
kind: string,
|
|
1078
|
+
namespace: string,
|
|
1079
|
+
name: string,
|
|
1080
|
+
category: PrometheusMetricCategory = 'cpu',
|
|
1081
|
+
range: PrometheusTimeRange = '1h',
|
|
1082
|
+
enabled = true,
|
|
1083
|
+
) {
|
|
1084
|
+
return useQuery<PrometheusResourceMetrics>({
|
|
1085
|
+
queryKey: ['prometheus-resource-metrics', kind, namespace, name, category, range],
|
|
1086
|
+
queryFn: () =>
|
|
1087
|
+
fetchJSON(
|
|
1088
|
+
namespace
|
|
1089
|
+
? `/prometheus/resources/${kind}/${namespace}/${name}?category=${category}&range=${range}`
|
|
1090
|
+
: `/prometheus/resources/${kind}/${name}?category=${category}&range=${range}`,
|
|
1091
|
+
),
|
|
1092
|
+
enabled,
|
|
1093
|
+
staleTime: 30000,
|
|
1094
|
+
refetchInterval: 60000,
|
|
1095
|
+
})
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Fetch Prometheus metrics for a namespace
|
|
1099
|
+
export function usePrometheusNamespaceMetrics(
|
|
1100
|
+
namespace: string,
|
|
1101
|
+
category: PrometheusMetricCategory = 'cpu',
|
|
1102
|
+
range: PrometheusTimeRange = '1h',
|
|
1103
|
+
enabled = true,
|
|
1104
|
+
) {
|
|
1105
|
+
return useQuery<PrometheusResourceMetrics>({
|
|
1106
|
+
queryKey: ['prometheus-namespace-metrics', namespace, category, range],
|
|
1107
|
+
queryFn: () =>
|
|
1108
|
+
fetchJSON(`/prometheus/namespace/${namespace}?category=${category}&range=${range}`),
|
|
1109
|
+
enabled,
|
|
1110
|
+
staleTime: 30000,
|
|
1111
|
+
refetchInterval: 60000,
|
|
1112
|
+
})
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Fetch Prometheus metrics for the entire cluster
|
|
1116
|
+
export function usePrometheusClusterMetrics(
|
|
1117
|
+
category: PrometheusMetricCategory = 'cpu',
|
|
1118
|
+
range: PrometheusTimeRange = '1h',
|
|
1119
|
+
enabled = true,
|
|
1120
|
+
) {
|
|
1121
|
+
return useQuery<PrometheusResourceMetrics>({
|
|
1122
|
+
queryKey: ['prometheus-cluster-metrics', category, range],
|
|
1123
|
+
queryFn: () =>
|
|
1124
|
+
fetchJSON(`/prometheus/cluster?category=${category}&range=${range}`),
|
|
1125
|
+
enabled,
|
|
1126
|
+
staleTime: 30000,
|
|
1127
|
+
refetchInterval: 60000,
|
|
1128
|
+
})
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ============================================================================
|
|
1132
|
+
// Pod Logs
|
|
1133
|
+
// ============================================================================
|
|
1134
|
+
|
|
1135
|
+
// Pod logs types
|
|
1136
|
+
export interface LogsResponse {
|
|
1137
|
+
podName: string
|
|
1138
|
+
namespace: string
|
|
1139
|
+
containers: string[]
|
|
1140
|
+
logs: Record<string, string> // container -> logs
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
export interface LogStreamEvent {
|
|
1144
|
+
event: 'connected' | 'log' | 'end' | 'error'
|
|
1145
|
+
data: {
|
|
1146
|
+
timestamp?: string
|
|
1147
|
+
content?: string
|
|
1148
|
+
container?: string
|
|
1149
|
+
pod?: string
|
|
1150
|
+
namespace?: string
|
|
1151
|
+
reason?: string
|
|
1152
|
+
error?: string
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Fetch pod logs (non-streaming)
|
|
1157
|
+
export function usePodLogs(namespace: string, podName: string, options?: {
|
|
1158
|
+
container?: string
|
|
1159
|
+
tailLines?: number
|
|
1160
|
+
previous?: boolean
|
|
1161
|
+
sinceSeconds?: number
|
|
1162
|
+
}) {
|
|
1163
|
+
const params = new URLSearchParams()
|
|
1164
|
+
if (options?.container) params.set('container', options.container)
|
|
1165
|
+
if (options?.tailLines) params.set('tailLines', String(options.tailLines))
|
|
1166
|
+
if (options?.previous) params.set('previous', 'true')
|
|
1167
|
+
if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
|
|
1168
|
+
const queryString = params.toString()
|
|
1169
|
+
|
|
1170
|
+
return useQuery<LogsResponse>({
|
|
1171
|
+
queryKey: ['pod-logs', namespace, podName, options?.container, options?.tailLines, options?.previous, options?.sinceSeconds],
|
|
1172
|
+
queryFn: () => fetchJSON(`/pods/${namespace}/${podName}/logs${queryString ? `?${queryString}` : ''}`),
|
|
1173
|
+
enabled: Boolean(namespace && podName),
|
|
1174
|
+
staleTime: 5000, // Allow refetch after 5 seconds
|
|
1175
|
+
})
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Create SSE connection for streaming logs
|
|
1179
|
+
export function createLogStream(
|
|
1180
|
+
namespace: string,
|
|
1181
|
+
podName: string,
|
|
1182
|
+
options?: {
|
|
1183
|
+
container?: string
|
|
1184
|
+
tailLines?: number
|
|
1185
|
+
previous?: boolean
|
|
1186
|
+
sinceSeconds?: number
|
|
1187
|
+
}
|
|
1188
|
+
): EventSource {
|
|
1189
|
+
const params = new URLSearchParams()
|
|
1190
|
+
if (options?.container) params.set('container', options.container)
|
|
1191
|
+
if (options?.tailLines) params.set('tailLines', String(options.tailLines))
|
|
1192
|
+
if (options?.previous) params.set('previous', 'true')
|
|
1193
|
+
if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
|
|
1194
|
+
const queryString = params.toString()
|
|
1195
|
+
|
|
1196
|
+
return new EventSource(`${getApiBase()}/pods/${namespace}/${podName}/logs/stream${queryString ? `?${queryString}` : ''}`, {
|
|
1197
|
+
withCredentials: getCredentialsMode() === 'include',
|
|
1198
|
+
})
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// ============================================================================
|
|
1202
|
+
// Port Forwarding
|
|
1203
|
+
// ============================================================================
|
|
1204
|
+
|
|
1205
|
+
export interface AvailablePort {
|
|
1206
|
+
port: number
|
|
1207
|
+
protocol: string
|
|
1208
|
+
containerName?: string
|
|
1209
|
+
name?: string
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
export function useAvailablePorts(type: 'pod' | 'service', namespace: string, name: string) {
|
|
1213
|
+
return useQuery<{ ports: AvailablePort[] }>({
|
|
1214
|
+
queryKey: ['available-ports', type, namespace, name],
|
|
1215
|
+
queryFn: () => fetchJSON(`/portforwards/available/${type}/${namespace}/${name}`),
|
|
1216
|
+
enabled: Boolean(namespace && name),
|
|
1217
|
+
staleTime: 30000,
|
|
1218
|
+
})
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ============================================================================
|
|
1222
|
+
// Resource Update/Delete mutations
|
|
1223
|
+
// ============================================================================
|
|
1224
|
+
|
|
1225
|
+
// Update a resource with new YAML
|
|
1226
|
+
export function useUpdateResource() {
|
|
1227
|
+
const queryClient = useQueryClient()
|
|
1228
|
+
|
|
1229
|
+
return useMutation({
|
|
1230
|
+
mutationFn: async ({ kind, namespace, name, yaml }: { kind: string; namespace: string; name: string; yaml: string }) => {
|
|
1231
|
+
const response = await apiFetch(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, {
|
|
1232
|
+
method: 'PUT',
|
|
1233
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
1234
|
+
body: yaml,
|
|
1235
|
+
})
|
|
1236
|
+
if (!response.ok) {
|
|
1237
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1238
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1239
|
+
}
|
|
1240
|
+
return response.json()
|
|
1241
|
+
},
|
|
1242
|
+
meta: {
|
|
1243
|
+
errorMessage: 'Failed to update resource',
|
|
1244
|
+
successMessage: 'Resource updated',
|
|
1245
|
+
},
|
|
1246
|
+
onSuccess: (_, variables) => {
|
|
1247
|
+
queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
|
|
1248
|
+
queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
|
|
1249
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1250
|
+
},
|
|
1251
|
+
})
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Cascade delete preview — shows resources that will be garbage-collected
|
|
1255
|
+
export interface CascadeDeletePreview {
|
|
1256
|
+
root: { kind: string; namespace: string; name: string; group?: string }
|
|
1257
|
+
dependents: { kind: string; namespace: string; name: string; group?: string }[]
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
export function useCascadeDeletePreview(kind: string, namespace: string, name: string, enabled: boolean) {
|
|
1261
|
+
return useQuery<CascadeDeletePreview>({
|
|
1262
|
+
queryKey: ['cascade-preview', kind, namespace, name],
|
|
1263
|
+
queryFn: () => fetchJSON<CascadeDeletePreview>(`/resources/${kind}/${namespace}/${name}/cascade-preview`),
|
|
1264
|
+
enabled,
|
|
1265
|
+
staleTime: 30_000,
|
|
1266
|
+
})
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Delete a resource
|
|
1270
|
+
export function useDeleteResource() {
|
|
1271
|
+
const queryClient = useQueryClient()
|
|
1272
|
+
|
|
1273
|
+
return useMutation({
|
|
1274
|
+
mutationFn: async ({ kind, namespace, name, force }: { kind: string; namespace: string; name: string; force?: boolean }) => {
|
|
1275
|
+
const url = new URL(`${getApiBase()}/resources/${kind}/${namespace}/${name}`, window.location.origin)
|
|
1276
|
+
if (force) {
|
|
1277
|
+
url.searchParams.set('force', 'true')
|
|
1278
|
+
}
|
|
1279
|
+
const response = await apiFetch(url.toString(), {
|
|
1280
|
+
method: 'DELETE',
|
|
1281
|
+
})
|
|
1282
|
+
if (!response.ok) {
|
|
1283
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1284
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1285
|
+
}
|
|
1286
|
+
// DELETE returns 204 No Content, no body to parse
|
|
1287
|
+
return { success: true }
|
|
1288
|
+
},
|
|
1289
|
+
meta: {
|
|
1290
|
+
errorMessage: 'Failed to delete resource',
|
|
1291
|
+
successMessage: 'Resource deleted',
|
|
1292
|
+
},
|
|
1293
|
+
onSuccess: (_, variables) => {
|
|
1294
|
+
queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
|
|
1295
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1296
|
+
},
|
|
1297
|
+
})
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Apply (create or update) a resource from YAML
|
|
1301
|
+
export interface ApplyResourceResult {
|
|
1302
|
+
name: string
|
|
1303
|
+
namespace: string
|
|
1304
|
+
kind: string
|
|
1305
|
+
created: boolean
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
export function useApplyResource() {
|
|
1309
|
+
const queryClient = useQueryClient()
|
|
1310
|
+
|
|
1311
|
+
return useMutation({
|
|
1312
|
+
mutationFn: async ({ yaml, mode = 'apply', dryRun = false }: { yaml: string; mode?: 'apply' | 'create'; dryRun?: boolean }) => {
|
|
1313
|
+
const url = new URL(`${getApiBase()}/resources/apply`, window.location.origin)
|
|
1314
|
+
url.searchParams.set('mode', mode)
|
|
1315
|
+
if (dryRun) {
|
|
1316
|
+
url.searchParams.set('dryRun', 'true')
|
|
1317
|
+
}
|
|
1318
|
+
const response = await apiFetch(url.toString(), {
|
|
1319
|
+
method: 'POST',
|
|
1320
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
1321
|
+
body: yaml,
|
|
1322
|
+
})
|
|
1323
|
+
if (!response.ok) {
|
|
1324
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1325
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1326
|
+
}
|
|
1327
|
+
return response.json() as Promise<ApplyResourceResult[]>
|
|
1328
|
+
},
|
|
1329
|
+
// No meta errorMessage/successMessage — the CreateResourceDialog
|
|
1330
|
+
// handles all feedback inline to avoid duplicate toasts.
|
|
1331
|
+
onSuccess: () => {
|
|
1332
|
+
queryClient.invalidateQueries({ queryKey: ['resources'] })
|
|
1333
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1334
|
+
},
|
|
1335
|
+
})
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// ============================================================================
|
|
1339
|
+
// CronJob operations
|
|
1340
|
+
// ============================================================================
|
|
1341
|
+
|
|
1342
|
+
// Trigger a CronJob (create a Job from it)
|
|
1343
|
+
export function useTriggerCronJob() {
|
|
1344
|
+
const queryClient = useQueryClient()
|
|
1345
|
+
|
|
1346
|
+
return useMutation({
|
|
1347
|
+
mutationFn: async ({ namespace, name }: { namespace: string; name: string }) => {
|
|
1348
|
+
const response = await apiFetch(`${getApiBase()}/cronjobs/${namespace}/${name}/trigger`, {
|
|
1349
|
+
method: 'POST',
|
|
1350
|
+
})
|
|
1351
|
+
if (!response.ok) {
|
|
1352
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1353
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1354
|
+
}
|
|
1355
|
+
return response.json()
|
|
1356
|
+
},
|
|
1357
|
+
meta: {
|
|
1358
|
+
errorMessage: 'Failed to trigger CronJob',
|
|
1359
|
+
successMessage: 'CronJob triggered',
|
|
1360
|
+
},
|
|
1361
|
+
onSuccess: () => {
|
|
1362
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
|
|
1363
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
|
|
1364
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1365
|
+
},
|
|
1366
|
+
})
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Suspend a CronJob
|
|
1370
|
+
export function useSuspendCronJob() {
|
|
1371
|
+
const queryClient = useQueryClient()
|
|
1372
|
+
|
|
1373
|
+
return useMutation({
|
|
1374
|
+
mutationFn: async ({ namespace, name }: { namespace: string; name: string }) => {
|
|
1375
|
+
const response = await apiFetch(`${getApiBase()}/cronjobs/${namespace}/${name}/suspend`, {
|
|
1376
|
+
method: 'POST',
|
|
1377
|
+
})
|
|
1378
|
+
if (!response.ok) {
|
|
1379
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1380
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1381
|
+
}
|
|
1382
|
+
return response.json()
|
|
1383
|
+
},
|
|
1384
|
+
meta: {
|
|
1385
|
+
errorMessage: 'Failed to suspend CronJob',
|
|
1386
|
+
successMessage: 'CronJob suspended',
|
|
1387
|
+
},
|
|
1388
|
+
onSuccess: () => {
|
|
1389
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
|
|
1390
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1391
|
+
},
|
|
1392
|
+
})
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Resume a suspended CronJob
|
|
1396
|
+
export function useResumeCronJob() {
|
|
1397
|
+
const queryClient = useQueryClient()
|
|
1398
|
+
|
|
1399
|
+
return useMutation({
|
|
1400
|
+
mutationFn: async ({ namespace, name }: { namespace: string; name: string }) => {
|
|
1401
|
+
const response = await apiFetch(`${getApiBase()}/cronjobs/${namespace}/${name}/resume`, {
|
|
1402
|
+
method: 'POST',
|
|
1403
|
+
})
|
|
1404
|
+
if (!response.ok) {
|
|
1405
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1406
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1407
|
+
}
|
|
1408
|
+
return response.json()
|
|
1409
|
+
},
|
|
1410
|
+
meta: {
|
|
1411
|
+
errorMessage: 'Failed to resume CronJob',
|
|
1412
|
+
successMessage: 'CronJob resumed',
|
|
1413
|
+
},
|
|
1414
|
+
onSuccess: () => {
|
|
1415
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
|
|
1416
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1417
|
+
},
|
|
1418
|
+
})
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ============================================================================
|
|
1422
|
+
// Workload operations
|
|
1423
|
+
// ============================================================================
|
|
1424
|
+
|
|
1425
|
+
// Restart a workload (Deployment, StatefulSet, DaemonSet, Rollout)
|
|
1426
|
+
export function useRestartWorkload() {
|
|
1427
|
+
const queryClient = useQueryClient()
|
|
1428
|
+
|
|
1429
|
+
return useMutation({
|
|
1430
|
+
mutationFn: async ({ kind, namespace, name }: { kind: string; namespace: string; name: string }) => {
|
|
1431
|
+
const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/restart`, {
|
|
1432
|
+
method: 'POST',
|
|
1433
|
+
})
|
|
1434
|
+
if (!response.ok) {
|
|
1435
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1436
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1437
|
+
}
|
|
1438
|
+
return response.json()
|
|
1439
|
+
},
|
|
1440
|
+
meta: {
|
|
1441
|
+
errorMessage: 'Failed to restart workload',
|
|
1442
|
+
successMessage: 'Workload restarting',
|
|
1443
|
+
},
|
|
1444
|
+
onSuccess: (_, variables) => {
|
|
1445
|
+
queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
|
|
1446
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1447
|
+
},
|
|
1448
|
+
})
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// Scale a workload (Deployment, StatefulSet)
|
|
1452
|
+
export function useScaleWorkload() {
|
|
1453
|
+
const queryClient = useQueryClient()
|
|
1454
|
+
|
|
1455
|
+
return useMutation({
|
|
1456
|
+
mutationFn: async ({ kind, namespace, name, replicas }: { kind: string; namespace: string; name: string; replicas: number }) => {
|
|
1457
|
+
const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/scale`, {
|
|
1458
|
+
method: 'POST',
|
|
1459
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1460
|
+
body: JSON.stringify({ replicas }),
|
|
1461
|
+
})
|
|
1462
|
+
if (!response.ok) {
|
|
1463
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1464
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1465
|
+
}
|
|
1466
|
+
return response.json()
|
|
1467
|
+
},
|
|
1468
|
+
meta: {
|
|
1469
|
+
errorMessage: 'Failed to scale workload',
|
|
1470
|
+
successMessage: 'Workload scaled',
|
|
1471
|
+
},
|
|
1472
|
+
onSuccess: (_, variables) => {
|
|
1473
|
+
queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
|
|
1474
|
+
queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
|
|
1475
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1476
|
+
},
|
|
1477
|
+
})
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// ============================================================================
|
|
1481
|
+
// Workload rollback
|
|
1482
|
+
// ============================================================================
|
|
1483
|
+
|
|
1484
|
+
// Workload revision history
|
|
1485
|
+
export interface WorkloadRevision {
|
|
1486
|
+
number: number
|
|
1487
|
+
createdAt: string
|
|
1488
|
+
image: string
|
|
1489
|
+
isCurrent: boolean
|
|
1490
|
+
replicas: number
|
|
1491
|
+
template?: string // Pod template spec as YAML (for revision diff)
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
export function useWorkloadRevisions(kind: string, namespace: string, name: string, enabled = true) {
|
|
1495
|
+
return useQuery<WorkloadRevision[]>({
|
|
1496
|
+
queryKey: ['workload-revisions', kind, namespace, name],
|
|
1497
|
+
queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/revisions`),
|
|
1498
|
+
enabled: Boolean(kind && namespace && name && enabled),
|
|
1499
|
+
})
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
export function useRollbackWorkload() {
|
|
1503
|
+
const queryClient = useQueryClient()
|
|
1504
|
+
return useMutation({
|
|
1505
|
+
mutationFn: async ({ kind, namespace, name, revision }: { kind: string; namespace: string; name: string; revision: number }) => {
|
|
1506
|
+
const response = await apiFetch(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/rollback`, {
|
|
1507
|
+
method: 'POST',
|
|
1508
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1509
|
+
body: JSON.stringify({ revision }),
|
|
1510
|
+
})
|
|
1511
|
+
if (!response.ok) {
|
|
1512
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1513
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1514
|
+
}
|
|
1515
|
+
return response.json()
|
|
1516
|
+
},
|
|
1517
|
+
meta: {
|
|
1518
|
+
errorMessage: 'Failed to rollback workload',
|
|
1519
|
+
successMessage: 'Rollback initiated',
|
|
1520
|
+
},
|
|
1521
|
+
onSuccess: (_, variables) => {
|
|
1522
|
+
queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
|
|
1523
|
+
queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
|
|
1524
|
+
queryClient.invalidateQueries({ queryKey: ['workload-revisions', variables.kind, variables.namespace, variables.name] })
|
|
1525
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1526
|
+
},
|
|
1527
|
+
})
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// ============================================================================
|
|
1531
|
+
// Node operations (cordon, uncordon, drain)
|
|
1532
|
+
// ============================================================================
|
|
1533
|
+
|
|
1534
|
+
export function useCordonNode() {
|
|
1535
|
+
const queryClient = useQueryClient()
|
|
1536
|
+
|
|
1537
|
+
return useMutation({
|
|
1538
|
+
mutationFn: async ({ name }: { name: string }) => {
|
|
1539
|
+
const response = await apiFetch(`${getApiBase()}/nodes/${name}/cordon`, {
|
|
1540
|
+
method: 'POST',
|
|
1541
|
+
})
|
|
1542
|
+
if (!response.ok) {
|
|
1543
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1544
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1545
|
+
}
|
|
1546
|
+
return response.json()
|
|
1547
|
+
},
|
|
1548
|
+
meta: {
|
|
1549
|
+
errorMessage: 'Failed to cordon node',
|
|
1550
|
+
successMessage: 'Node cordoned',
|
|
1551
|
+
},
|
|
1552
|
+
onSuccess: (_, variables) => {
|
|
1553
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
|
|
1554
|
+
queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
|
|
1555
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1556
|
+
},
|
|
1557
|
+
})
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
export function useUncordonNode() {
|
|
1561
|
+
const queryClient = useQueryClient()
|
|
1562
|
+
|
|
1563
|
+
return useMutation({
|
|
1564
|
+
mutationFn: async ({ name }: { name: string }) => {
|
|
1565
|
+
const response = await apiFetch(`${getApiBase()}/nodes/${name}/uncordon`, {
|
|
1566
|
+
method: 'POST',
|
|
1567
|
+
})
|
|
1568
|
+
if (!response.ok) {
|
|
1569
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1570
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1571
|
+
}
|
|
1572
|
+
return response.json()
|
|
1573
|
+
},
|
|
1574
|
+
meta: {
|
|
1575
|
+
errorMessage: 'Failed to uncordon node',
|
|
1576
|
+
successMessage: 'Node uncordoned',
|
|
1577
|
+
},
|
|
1578
|
+
onSuccess: (_, variables) => {
|
|
1579
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
|
|
1580
|
+
queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
|
|
1581
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1582
|
+
},
|
|
1583
|
+
})
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
export interface DrainNodeOptions {
|
|
1587
|
+
deleteEmptyDirData?: boolean
|
|
1588
|
+
force?: boolean
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
export function useDrainNode() {
|
|
1592
|
+
const queryClient = useQueryClient()
|
|
1593
|
+
|
|
1594
|
+
return useMutation({
|
|
1595
|
+
mutationFn: async ({ name, options }: { name: string; options?: DrainNodeOptions }) => {
|
|
1596
|
+
const response = await apiFetch(`${getApiBase()}/nodes/${name}/drain`, {
|
|
1597
|
+
method: 'POST',
|
|
1598
|
+
headers: options ? { 'Content-Type': 'application/json' } : {},
|
|
1599
|
+
body: options ? JSON.stringify(options) : undefined,
|
|
1600
|
+
})
|
|
1601
|
+
if (!response.ok) {
|
|
1602
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1603
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1604
|
+
}
|
|
1605
|
+
return response.json()
|
|
1606
|
+
},
|
|
1607
|
+
meta: {
|
|
1608
|
+
errorMessage: 'Failed to drain node',
|
|
1609
|
+
// No static successMessage — handled in onSuccess to distinguish partial failures
|
|
1610
|
+
},
|
|
1611
|
+
onSuccess: (data: { evictedPods?: string[]; errors?: string[] }, variables) => {
|
|
1612
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'nodes'] })
|
|
1613
|
+
queryClient.invalidateQueries({ queryKey: ['resource', 'nodes', '', variables.name] })
|
|
1614
|
+
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1615
|
+
|
|
1616
|
+
const evicted = data?.evictedPods?.length ?? 0
|
|
1617
|
+
const errors = data?.errors?.length ?? 0
|
|
1618
|
+
if (errors > 0) {
|
|
1619
|
+
showApiError(
|
|
1620
|
+
`Drain completed with ${errors} error(s)`,
|
|
1621
|
+
`${evicted} pods evicted. Errors: ${data.errors!.join('; ')}`,
|
|
1622
|
+
)
|
|
1623
|
+
} else {
|
|
1624
|
+
showApiSuccess(`Node drained: ${evicted} pods evicted`)
|
|
1625
|
+
}
|
|
1626
|
+
},
|
|
1627
|
+
})
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// ============================================================================
|
|
1631
|
+
// Helm API hooks
|
|
1632
|
+
// ============================================================================
|
|
1633
|
+
|
|
1634
|
+
// List all Helm releases
|
|
1635
|
+
export function useHelmReleases(namespace?: string) {
|
|
1636
|
+
const params = namespace ? `?namespace=${namespace}` : ''
|
|
1637
|
+
return useQuery<HelmRelease[]>({
|
|
1638
|
+
queryKey: ['helm-releases', namespace],
|
|
1639
|
+
queryFn: () => fetchJSON(`/helm/releases${params}`),
|
|
1640
|
+
staleTime: 30000, // 30 seconds
|
|
1641
|
+
})
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// Get details for a specific Helm release
|
|
1645
|
+
export function useHelmRelease(namespace: string, name: string) {
|
|
1646
|
+
return useQuery<HelmReleaseDetail>({
|
|
1647
|
+
queryKey: ['helm-release', namespace, name],
|
|
1648
|
+
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}`),
|
|
1649
|
+
enabled: Boolean(namespace && name),
|
|
1650
|
+
staleTime: 5000,
|
|
1651
|
+
refetchInterval: 10000, // Poll for live resource status updates (post-upgrade/rollback)
|
|
1652
|
+
})
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// Get manifest for a Helm release (optionally at a specific revision)
|
|
1656
|
+
export function useHelmManifest(namespace: string, name: string, revision?: number) {
|
|
1657
|
+
const params = revision ? `?revision=${revision}` : ''
|
|
1658
|
+
return useQuery<string>({
|
|
1659
|
+
queryKey: ['helm-manifest', namespace, name, revision],
|
|
1660
|
+
queryFn: async () => {
|
|
1661
|
+
const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/manifest${params}`)
|
|
1662
|
+
if (!response.ok) {
|
|
1663
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1664
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1665
|
+
}
|
|
1666
|
+
return response.text()
|
|
1667
|
+
},
|
|
1668
|
+
enabled: Boolean(namespace && name),
|
|
1669
|
+
staleTime: 60000, // 1 minute
|
|
1670
|
+
})
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Get values for a Helm release
|
|
1674
|
+
export function useHelmValues(namespace: string, name: string, allValues?: boolean) {
|
|
1675
|
+
const params = allValues ? '?all=true' : ''
|
|
1676
|
+
return useQuery<HelmValues>({
|
|
1677
|
+
queryKey: ['helm-values', namespace, name, allValues],
|
|
1678
|
+
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${params}`),
|
|
1679
|
+
enabled: Boolean(namespace && name),
|
|
1680
|
+
staleTime: 60000,
|
|
1681
|
+
})
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// Get diff between two revisions
|
|
1685
|
+
export function useHelmManifestDiff(
|
|
1686
|
+
namespace: string,
|
|
1687
|
+
name: string,
|
|
1688
|
+
revision1: number,
|
|
1689
|
+
revision2: number
|
|
1690
|
+
) {
|
|
1691
|
+
return useQuery<ManifestDiff>({
|
|
1692
|
+
queryKey: ['helm-diff', namespace, name, revision1, revision2],
|
|
1693
|
+
queryFn: () =>
|
|
1694
|
+
fetchJSON(`/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`),
|
|
1695
|
+
enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2),
|
|
1696
|
+
staleTime: 60000,
|
|
1697
|
+
})
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// Check for upgrade availability (lazy - called when drawer opens)
|
|
1701
|
+
export function useHelmUpgradeInfo(namespace: string, name: string, enabled = true) {
|
|
1702
|
+
return useQuery<UpgradeInfo>({
|
|
1703
|
+
queryKey: ['helm-upgrade-info', namespace, name],
|
|
1704
|
+
queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/upgrade-info`),
|
|
1705
|
+
enabled: Boolean(namespace && name && enabled),
|
|
1706
|
+
staleTime: 30000, // 30 seconds - keep in sync with release list
|
|
1707
|
+
retry: false, // Don't retry on failure - repo might not be configured
|
|
1708
|
+
})
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// Batch check for upgrade availability (for list view)
|
|
1712
|
+
export function useHelmBatchUpgradeInfo(namespace?: string, enabled = true) {
|
|
1713
|
+
const params = namespace ? `?namespace=${namespace}` : ''
|
|
1714
|
+
return useQuery<BatchUpgradeInfo>({
|
|
1715
|
+
queryKey: ['helm-batch-upgrade-info', namespace],
|
|
1716
|
+
queryFn: () => fetchJSON(`/helm/upgrade-check${params}`),
|
|
1717
|
+
enabled,
|
|
1718
|
+
staleTime: 30000, // 30 seconds - keep in sync with release list
|
|
1719
|
+
retry: false,
|
|
1720
|
+
})
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// ============================================================================
|
|
1724
|
+
// Helm Actions (mutations)
|
|
1725
|
+
// ============================================================================
|
|
1726
|
+
|
|
1727
|
+
// Rollback a release to a previous revision
|
|
1728
|
+
export function useHelmRollback() {
|
|
1729
|
+
const queryClient = useQueryClient()
|
|
1730
|
+
|
|
1731
|
+
return useMutation({
|
|
1732
|
+
mutationFn: async ({ namespace, name, revision }: { namespace: string; name: string; revision: number }) => {
|
|
1733
|
+
const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/rollback?revision=${revision}`, {
|
|
1734
|
+
method: 'POST',
|
|
1735
|
+
})
|
|
1736
|
+
if (!response.ok) {
|
|
1737
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1738
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1739
|
+
}
|
|
1740
|
+
return response.json()
|
|
1741
|
+
},
|
|
1742
|
+
meta: {
|
|
1743
|
+
errorMessage: 'Rollback failed',
|
|
1744
|
+
successMessage: 'Release rolled back',
|
|
1745
|
+
},
|
|
1746
|
+
onSuccess: (_, variables) => {
|
|
1747
|
+
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
1748
|
+
queryClient.invalidateQueries({ queryKey: ['helm-release', variables.namespace, variables.name] })
|
|
1749
|
+
},
|
|
1750
|
+
})
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// Uninstall a release
|
|
1754
|
+
export function useHelmUninstall() {
|
|
1755
|
+
const queryClient = useQueryClient()
|
|
1756
|
+
|
|
1757
|
+
return useMutation({
|
|
1758
|
+
mutationFn: async ({ namespace, name }: { namespace: string; name: string }) => {
|
|
1759
|
+
const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}`, {
|
|
1760
|
+
method: 'DELETE',
|
|
1761
|
+
})
|
|
1762
|
+
if (!response.ok) {
|
|
1763
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1764
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1765
|
+
}
|
|
1766
|
+
return response.json()
|
|
1767
|
+
},
|
|
1768
|
+
meta: {
|
|
1769
|
+
errorMessage: 'Uninstall failed',
|
|
1770
|
+
successMessage: 'Release uninstalled',
|
|
1771
|
+
},
|
|
1772
|
+
onSuccess: () => {
|
|
1773
|
+
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
1774
|
+
queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
|
|
1775
|
+
},
|
|
1776
|
+
})
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// Stream SSE progress events from a Helm operation endpoint.
|
|
1780
|
+
// Resolves on 'complete', rejects on 'error'. Returns the complete event data for install (which includes release).
|
|
1781
|
+
function streamHelmProgress(
|
|
1782
|
+
url: string,
|
|
1783
|
+
options: RequestInit,
|
|
1784
|
+
onProgress: (event: InstallProgressEvent) => void,
|
|
1785
|
+
failureLabel: string,
|
|
1786
|
+
): Promise<InstallProgressEvent> {
|
|
1787
|
+
const headers = new Headers(options.headers)
|
|
1788
|
+
for (const [k, v] of Object.entries(getAuthHeaders())) {
|
|
1789
|
+
if (!headers.has(k)) headers.set(k, v)
|
|
1790
|
+
}
|
|
1791
|
+
return new Promise((resolve, reject) => {
|
|
1792
|
+
fetch(url, { credentials: getCredentialsMode(), ...options, headers })
|
|
1793
|
+
.then(async (response) => {
|
|
1794
|
+
if (!response.ok) {
|
|
1795
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1796
|
+
reject(new Error(error.error || `HTTP ${response.status}`))
|
|
1797
|
+
return
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
const reader = response.body?.getReader()
|
|
1801
|
+
if (!reader) {
|
|
1802
|
+
reject(new Error('No response body'))
|
|
1803
|
+
return
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const decoder = new TextDecoder()
|
|
1807
|
+
let buffer = ''
|
|
1808
|
+
|
|
1809
|
+
while (true) {
|
|
1810
|
+
const { done, value } = await reader.read()
|
|
1811
|
+
if (done) break
|
|
1812
|
+
|
|
1813
|
+
buffer += decoder.decode(value, { stream: true })
|
|
1814
|
+
|
|
1815
|
+
const lines = buffer.split('\n')
|
|
1816
|
+
buffer = lines.pop() || ''
|
|
1817
|
+
|
|
1818
|
+
for (const line of lines) {
|
|
1819
|
+
if (line.startsWith('data: ')) {
|
|
1820
|
+
try {
|
|
1821
|
+
const data = JSON.parse(line.slice(6)) as InstallProgressEvent
|
|
1822
|
+
onProgress(data)
|
|
1823
|
+
|
|
1824
|
+
if (data.type === 'complete') {
|
|
1825
|
+
resolve(data)
|
|
1826
|
+
} else if (data.type === 'error') {
|
|
1827
|
+
reject(new Error(data.message || failureLabel))
|
|
1828
|
+
}
|
|
1829
|
+
} catch {
|
|
1830
|
+
// Ignore parse errors
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
})
|
|
1836
|
+
.catch(reject)
|
|
1837
|
+
})
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// Upgrade a release with progress streaming via SSE
|
|
1841
|
+
export function upgradeWithProgress(
|
|
1842
|
+
namespace: string,
|
|
1843
|
+
name: string,
|
|
1844
|
+
version: string,
|
|
1845
|
+
onProgress: (event: InstallProgressEvent) => void
|
|
1846
|
+
): Promise<void> {
|
|
1847
|
+
return streamHelmProgress(
|
|
1848
|
+
`${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?version=${encodeURIComponent(version)}`,
|
|
1849
|
+
{ method: 'POST' },
|
|
1850
|
+
onProgress,
|
|
1851
|
+
'Upgrade failed',
|
|
1852
|
+
).then(() => {})
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// Rollback a release with progress streaming via SSE
|
|
1856
|
+
export function rollbackWithProgress(
|
|
1857
|
+
namespace: string,
|
|
1858
|
+
name: string,
|
|
1859
|
+
revision: number,
|
|
1860
|
+
onProgress: (event: InstallProgressEvent) => void
|
|
1861
|
+
): Promise<void> {
|
|
1862
|
+
return streamHelmProgress(
|
|
1863
|
+
`${getApiBase()}/helm/releases/${namespace}/${name}/rollback-stream?revision=${revision}`,
|
|
1864
|
+
{ method: 'POST' },
|
|
1865
|
+
onProgress,
|
|
1866
|
+
'Rollback failed',
|
|
1867
|
+
).then(() => {})
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// Preview values change (dry-run upgrade)
|
|
1871
|
+
export function useHelmPreviewValues() {
|
|
1872
|
+
return useMutation<ValuesPreviewResponse, Error, { namespace: string; name: string; values: Record<string, unknown> }>({
|
|
1873
|
+
mutationFn: async ({ namespace, name, values }) => {
|
|
1874
|
+
const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`, {
|
|
1875
|
+
method: 'POST',
|
|
1876
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1877
|
+
body: JSON.stringify({ values }),
|
|
1878
|
+
})
|
|
1879
|
+
if (!response.ok) {
|
|
1880
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1881
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1882
|
+
}
|
|
1883
|
+
return response.json()
|
|
1884
|
+
},
|
|
1885
|
+
})
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// Apply new values to a release
|
|
1889
|
+
export function useHelmApplyValues() {
|
|
1890
|
+
const queryClient = useQueryClient()
|
|
1891
|
+
|
|
1892
|
+
return useMutation({
|
|
1893
|
+
mutationFn: async ({ namespace, name, values }: { namespace: string; name: string; values: Record<string, unknown> }) => {
|
|
1894
|
+
const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values`, {
|
|
1895
|
+
method: 'PUT',
|
|
1896
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1897
|
+
body: JSON.stringify({ values }),
|
|
1898
|
+
})
|
|
1899
|
+
if (!response.ok) {
|
|
1900
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1901
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1902
|
+
}
|
|
1903
|
+
return response.json()
|
|
1904
|
+
},
|
|
1905
|
+
meta: {
|
|
1906
|
+
errorMessage: 'Failed to apply values',
|
|
1907
|
+
successMessage: 'Values applied',
|
|
1908
|
+
},
|
|
1909
|
+
onSuccess: (_, variables) => {
|
|
1910
|
+
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
1911
|
+
queryClient.invalidateQueries({ queryKey: ['helm-release', variables.namespace, variables.name] })
|
|
1912
|
+
queryClient.invalidateQueries({ queryKey: ['helm-values', variables.namespace, variables.name] })
|
|
1913
|
+
},
|
|
1914
|
+
})
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// ============================================================================
|
|
1918
|
+
// Chart Browser API hooks
|
|
1919
|
+
// ============================================================================
|
|
1920
|
+
|
|
1921
|
+
// List configured Helm repositories
|
|
1922
|
+
export function useHelmRepositories() {
|
|
1923
|
+
return useQuery<HelmRepository[]>({
|
|
1924
|
+
queryKey: ['helm-repositories'],
|
|
1925
|
+
queryFn: () => fetchJSON('/helm/repositories'),
|
|
1926
|
+
})
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// Update a repository index
|
|
1930
|
+
export function useUpdateRepository() {
|
|
1931
|
+
const queryClient = useQueryClient()
|
|
1932
|
+
|
|
1933
|
+
return useMutation({
|
|
1934
|
+
mutationFn: async (repoName: string) => {
|
|
1935
|
+
const response = await apiFetch(`${getApiBase()}/helm/repositories/${repoName}/update`, {
|
|
1936
|
+
method: 'POST',
|
|
1937
|
+
})
|
|
1938
|
+
if (!response.ok) {
|
|
1939
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1940
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1941
|
+
}
|
|
1942
|
+
return response.json()
|
|
1943
|
+
},
|
|
1944
|
+
meta: {
|
|
1945
|
+
errorMessage: 'Failed to update repository',
|
|
1946
|
+
successMessage: 'Repository updated',
|
|
1947
|
+
},
|
|
1948
|
+
onSuccess: () => {
|
|
1949
|
+
queryClient.invalidateQueries({ queryKey: ['helm-repositories'] })
|
|
1950
|
+
queryClient.invalidateQueries({ queryKey: ['helm-charts'] })
|
|
1951
|
+
},
|
|
1952
|
+
})
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// Search charts across all repositories
|
|
1956
|
+
export function useSearchCharts(query: string, allVersions = false, enabled = true) {
|
|
1957
|
+
return useQuery<ChartSearchResult>({
|
|
1958
|
+
queryKey: ['helm-charts', query, allVersions],
|
|
1959
|
+
queryFn: () => {
|
|
1960
|
+
const params = new URLSearchParams()
|
|
1961
|
+
if (query) params.set('query', query)
|
|
1962
|
+
if (allVersions) params.set('allVersions', 'true')
|
|
1963
|
+
return fetchJSON(`/helm/charts?${params.toString()}`)
|
|
1964
|
+
},
|
|
1965
|
+
enabled,
|
|
1966
|
+
})
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// Get chart detail
|
|
1970
|
+
export function useChartDetail(repo: string, chart: string, version?: string, enabled = true) {
|
|
1971
|
+
return useQuery<ChartDetail>({
|
|
1972
|
+
queryKey: ['helm-chart-detail', repo, chart, version],
|
|
1973
|
+
queryFn: () => {
|
|
1974
|
+
const path = version
|
|
1975
|
+
? `/helm/charts/${repo}/${chart}/${version}`
|
|
1976
|
+
: `/helm/charts/${repo}/${chart}`
|
|
1977
|
+
return fetchJSON(path)
|
|
1978
|
+
},
|
|
1979
|
+
enabled: enabled && Boolean(repo && chart),
|
|
1980
|
+
})
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// Install a new chart (non-streaming)
|
|
1984
|
+
export function useInstallChart() {
|
|
1985
|
+
const queryClient = useQueryClient()
|
|
1986
|
+
|
|
1987
|
+
return useMutation({
|
|
1988
|
+
mutationFn: async (req: InstallChartRequest) => {
|
|
1989
|
+
const response = await apiFetch(`${getApiBase()}/helm/releases`, {
|
|
1990
|
+
method: 'POST',
|
|
1991
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1992
|
+
body: JSON.stringify(req),
|
|
1993
|
+
})
|
|
1994
|
+
if (!response.ok) {
|
|
1995
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
1996
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
1997
|
+
}
|
|
1998
|
+
return response.json() as Promise<HelmRelease>
|
|
1999
|
+
},
|
|
2000
|
+
meta: {
|
|
2001
|
+
errorMessage: 'Installation failed',
|
|
2002
|
+
successMessage: 'Chart installed',
|
|
2003
|
+
},
|
|
2004
|
+
onSuccess: () => {
|
|
2005
|
+
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
2006
|
+
},
|
|
2007
|
+
})
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// Install progress event types
|
|
2011
|
+
export interface InstallProgressEvent {
|
|
2012
|
+
type: 'progress' | 'complete' | 'error'
|
|
2013
|
+
phase?: string
|
|
2014
|
+
message?: string
|
|
2015
|
+
detail?: string
|
|
2016
|
+
release?: HelmRelease
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// Install a chart with progress streaming via SSE
|
|
2020
|
+
export function installChartWithProgress(
|
|
2021
|
+
req: InstallChartRequest,
|
|
2022
|
+
onProgress: (event: InstallProgressEvent) => void
|
|
2023
|
+
): Promise<HelmRelease> {
|
|
2024
|
+
return streamHelmProgress(
|
|
2025
|
+
`${getApiBase()}/helm/releases/install-stream`,
|
|
2026
|
+
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req) },
|
|
2027
|
+
onProgress,
|
|
2028
|
+
'Install failed',
|
|
2029
|
+
).then((event) => event.release as HelmRelease)
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// ============================================================================
|
|
2033
|
+
// ArtifactHub API hooks
|
|
2034
|
+
// ============================================================================
|
|
2035
|
+
|
|
2036
|
+
// Sort options for ArtifactHub search
|
|
2037
|
+
export type ArtifactHubSortOption = 'relevance' | 'stars' | 'last_updated'
|
|
2038
|
+
|
|
2039
|
+
// Search charts on ArtifactHub
|
|
2040
|
+
export function useArtifactHubSearch(
|
|
2041
|
+
query: string,
|
|
2042
|
+
options?: { offset?: number; limit?: number; official?: boolean; verified?: boolean; sort?: ArtifactHubSortOption },
|
|
2043
|
+
enabled = true
|
|
2044
|
+
) {
|
|
2045
|
+
const params = new URLSearchParams()
|
|
2046
|
+
if (query) params.set('query', query)
|
|
2047
|
+
if (options?.offset) params.set('offset', String(options.offset))
|
|
2048
|
+
if (options?.limit) params.set('limit', String(options.limit))
|
|
2049
|
+
if (options?.official) params.set('official', 'true')
|
|
2050
|
+
if (options?.verified) params.set('verified', 'true')
|
|
2051
|
+
if (options?.sort && options.sort !== 'relevance') params.set('sort', options.sort)
|
|
2052
|
+
|
|
2053
|
+
return useQuery<ArtifactHubSearchResult>({
|
|
2054
|
+
queryKey: ['artifacthub-search', query, options?.offset, options?.limit, options?.official, options?.verified, options?.sort],
|
|
2055
|
+
queryFn: () => fetchJSON(`/helm/artifacthub/search?${params.toString()}`),
|
|
2056
|
+
enabled: enabled && query.length > 0,
|
|
2057
|
+
staleTime: 60000, // 1 minute
|
|
2058
|
+
})
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// Get chart detail from ArtifactHub
|
|
2062
|
+
export function useArtifactHubChart(repoName: string, chartName: string, version?: string, enabled = true) {
|
|
2063
|
+
const path = version
|
|
2064
|
+
? `/helm/artifacthub/charts/${repoName}/${chartName}/${version}`
|
|
2065
|
+
: `/helm/artifacthub/charts/${repoName}/${chartName}`
|
|
2066
|
+
|
|
2067
|
+
return useQuery<ArtifactHubChartDetail>({
|
|
2068
|
+
queryKey: ['artifacthub-chart', repoName, chartName, version],
|
|
2069
|
+
queryFn: () => fetchJSON(path),
|
|
2070
|
+
enabled: enabled && Boolean(repoName && chartName),
|
|
2071
|
+
staleTime: 60000,
|
|
2072
|
+
})
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// ============================================================================
|
|
2076
|
+
// GitOps Mutation Factory
|
|
2077
|
+
// ============================================================================
|
|
2078
|
+
|
|
2079
|
+
interface GitOpsMutationConfig<TVariables> {
|
|
2080
|
+
getPath: (variables: TVariables) => string
|
|
2081
|
+
errorMessage: string
|
|
2082
|
+
successMessage: string
|
|
2083
|
+
getInvalidateKeys: (variables: TVariables) => (string | undefined)[][]
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
/**
|
|
2087
|
+
* Factory function for creating GitOps mutation hooks with consistent patterns.
|
|
2088
|
+
* Handles fetch, error handling, meta messages, and query invalidation.
|
|
2089
|
+
*/
|
|
2090
|
+
function createGitOpsMutation<TVariables>(config: GitOpsMutationConfig<TVariables>) {
|
|
2091
|
+
return function useGitOpsMutation() {
|
|
2092
|
+
const queryClient = useQueryClient()
|
|
2093
|
+
return useMutation<GitOpsOperationResponse, Error, TVariables>({
|
|
2094
|
+
mutationFn: async (variables: TVariables): Promise<GitOpsOperationResponse> => {
|
|
2095
|
+
const response = await apiFetch(`${getApiBase()}${config.getPath(variables)}`, {
|
|
2096
|
+
method: 'POST',
|
|
2097
|
+
})
|
|
2098
|
+
if (!response.ok) {
|
|
2099
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2100
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
2101
|
+
}
|
|
2102
|
+
return response.json() as Promise<GitOpsOperationResponse>
|
|
2103
|
+
},
|
|
2104
|
+
meta: {
|
|
2105
|
+
errorMessage: config.errorMessage,
|
|
2106
|
+
successMessage: config.successMessage,
|
|
2107
|
+
},
|
|
2108
|
+
onSuccess: (_, variables) => {
|
|
2109
|
+
config.getInvalidateKeys(variables).forEach(key =>
|
|
2110
|
+
queryClient.invalidateQueries({ queryKey: key })
|
|
2111
|
+
)
|
|
2112
|
+
},
|
|
2113
|
+
})
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// Common variable types
|
|
2118
|
+
type FluxResourceVars = { kind: string; namespace: string; name: string }
|
|
2119
|
+
type ArgoAppVars = { namespace: string; name: string }
|
|
2120
|
+
|
|
2121
|
+
// Standard invalidation patterns
|
|
2122
|
+
const fluxInvalidateKeys = (v: FluxResourceVars) => [
|
|
2123
|
+
['resources', v.kind],
|
|
2124
|
+
['resource', v.kind, v.namespace, v.name],
|
|
2125
|
+
]
|
|
2126
|
+
const argoInvalidateKeys = (v: ArgoAppVars) => [
|
|
2127
|
+
['resources', 'applications'],
|
|
2128
|
+
['resource', 'applications', v.namespace, v.name],
|
|
2129
|
+
]
|
|
2130
|
+
|
|
2131
|
+
// ============================================================================
|
|
2132
|
+
// FluxCD API hooks
|
|
2133
|
+
// ============================================================================
|
|
2134
|
+
|
|
2135
|
+
export const useFluxReconcile = createGitOpsMutation<FluxResourceVars>({
|
|
2136
|
+
getPath: (v) => `/flux/${v.kind}/${v.namespace}/${v.name}/reconcile`,
|
|
2137
|
+
errorMessage: 'Failed to trigger reconciliation',
|
|
2138
|
+
successMessage: 'Reconciliation triggered',
|
|
2139
|
+
getInvalidateKeys: fluxInvalidateKeys,
|
|
2140
|
+
})
|
|
2141
|
+
|
|
2142
|
+
export const useFluxSuspend = createGitOpsMutation<FluxResourceVars>({
|
|
2143
|
+
getPath: (v) => `/flux/${v.kind}/${v.namespace}/${v.name}/suspend`,
|
|
2144
|
+
errorMessage: 'Failed to suspend resource',
|
|
2145
|
+
successMessage: 'Resource suspended',
|
|
2146
|
+
getInvalidateKeys: fluxInvalidateKeys,
|
|
2147
|
+
})
|
|
2148
|
+
|
|
2149
|
+
export const useFluxResume = createGitOpsMutation<FluxResourceVars>({
|
|
2150
|
+
getPath: (v) => `/flux/${v.kind}/${v.namespace}/${v.name}/resume`,
|
|
2151
|
+
errorMessage: 'Failed to resume resource',
|
|
2152
|
+
successMessage: 'Resource resumed',
|
|
2153
|
+
getInvalidateKeys: fluxInvalidateKeys,
|
|
2154
|
+
})
|
|
2155
|
+
|
|
2156
|
+
export const useFluxSyncWithSource = createGitOpsMutation<FluxResourceVars>({
|
|
2157
|
+
getPath: (v) => `/flux/${v.kind}/${v.namespace}/${v.name}/sync-with-source`,
|
|
2158
|
+
errorMessage: 'Failed to sync with source',
|
|
2159
|
+
successMessage: 'Sync with source triggered',
|
|
2160
|
+
getInvalidateKeys: (v) => [
|
|
2161
|
+
...fluxInvalidateKeys(v),
|
|
2162
|
+
// Also invalidate source resources as they were reconciled too
|
|
2163
|
+
['resources', 'gitrepositories'],
|
|
2164
|
+
['resources', 'ocirepositories'],
|
|
2165
|
+
['resources', 'helmrepositories'],
|
|
2166
|
+
],
|
|
2167
|
+
})
|
|
2168
|
+
|
|
2169
|
+
// ============================================================================
|
|
2170
|
+
// ArgoCD API hooks
|
|
2171
|
+
// ============================================================================
|
|
2172
|
+
|
|
2173
|
+
export const useArgoSync = createGitOpsMutation<ArgoAppVars>({
|
|
2174
|
+
getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/sync`,
|
|
2175
|
+
errorMessage: 'Failed to trigger sync',
|
|
2176
|
+
successMessage: 'Sync initiated',
|
|
2177
|
+
getInvalidateKeys: argoInvalidateKeys,
|
|
2178
|
+
})
|
|
2179
|
+
|
|
2180
|
+
export const useArgoTerminate = createGitOpsMutation<ArgoAppVars>({
|
|
2181
|
+
getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/terminate`,
|
|
2182
|
+
errorMessage: 'Failed to terminate sync',
|
|
2183
|
+
successMessage: 'Sync terminated',
|
|
2184
|
+
getInvalidateKeys: argoInvalidateKeys,
|
|
2185
|
+
})
|
|
2186
|
+
|
|
2187
|
+
export const useArgoSuspend = createGitOpsMutation<ArgoAppVars>({
|
|
2188
|
+
getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/suspend`,
|
|
2189
|
+
errorMessage: 'Failed to suspend application',
|
|
2190
|
+
successMessage: 'Application suspended',
|
|
2191
|
+
getInvalidateKeys: argoInvalidateKeys,
|
|
2192
|
+
})
|
|
2193
|
+
|
|
2194
|
+
export const useArgoResume = createGitOpsMutation<ArgoAppVars>({
|
|
2195
|
+
getPath: (v) => `/argo/applications/${v.namespace}/${v.name}/resume`,
|
|
2196
|
+
errorMessage: 'Failed to resume application',
|
|
2197
|
+
successMessage: 'Application resumed',
|
|
2198
|
+
getInvalidateKeys: argoInvalidateKeys,
|
|
2199
|
+
})
|
|
2200
|
+
|
|
2201
|
+
// useArgoRefresh has a unique parameter (hard), so it's defined separately
|
|
2202
|
+
export function useArgoRefresh() {
|
|
2203
|
+
const queryClient = useQueryClient()
|
|
2204
|
+
|
|
2205
|
+
return useMutation({
|
|
2206
|
+
mutationFn: async ({ namespace, name, hard = false }: { namespace: string; name: string; hard?: boolean }) => {
|
|
2207
|
+
const params = hard ? '?type=hard' : ''
|
|
2208
|
+
const response = await apiFetch(`${getApiBase()}/argo/applications/${namespace}/${name}/refresh${params}`, {
|
|
2209
|
+
method: 'POST',
|
|
2210
|
+
})
|
|
2211
|
+
if (!response.ok) {
|
|
2212
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2213
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
2214
|
+
}
|
|
2215
|
+
return response.json()
|
|
2216
|
+
},
|
|
2217
|
+
meta: {
|
|
2218
|
+
errorMessage: 'Failed to refresh application',
|
|
2219
|
+
successMessage: 'Application refreshed',
|
|
2220
|
+
},
|
|
2221
|
+
onSuccess: (_, variables) => {
|
|
2222
|
+
queryClient.invalidateQueries({ queryKey: ['resources', 'applications'] })
|
|
2223
|
+
queryClient.invalidateQueries({ queryKey: ['resource', 'applications', variables.namespace, variables.name] })
|
|
2224
|
+
},
|
|
2225
|
+
})
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
// ============================================================================
|
|
2229
|
+
// Context Switching API hooks
|
|
2230
|
+
// ============================================================================
|
|
2231
|
+
|
|
2232
|
+
// List all available kubeconfig contexts
|
|
2233
|
+
export function useContexts() {
|
|
2234
|
+
return useQuery<ContextInfo[]>({
|
|
2235
|
+
queryKey: ['contexts'],
|
|
2236
|
+
queryFn: () => fetchJSON('/contexts'),
|
|
2237
|
+
staleTime: 30000, // 30 seconds
|
|
2238
|
+
})
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
// Session counts for context switch confirmation
|
|
2242
|
+
export interface SessionCounts {
|
|
2243
|
+
portForwards: number
|
|
2244
|
+
execSessions: number
|
|
2245
|
+
total: number
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// Fetch current session counts (port forwards + exec sessions)
|
|
2249
|
+
export async function fetchSessionCounts(): Promise<SessionCounts> {
|
|
2250
|
+
return fetchJSON('/sessions')
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// Context switch timeout in milliseconds (should be longer than backend timeout)
|
|
2254
|
+
const CONTEXT_SWITCH_TIMEOUT = 45000 // 45 seconds
|
|
2255
|
+
|
|
2256
|
+
// Switch to a different context
|
|
2257
|
+
export function useSwitchContext() {
|
|
2258
|
+
const queryClient = useQueryClient()
|
|
2259
|
+
|
|
2260
|
+
return useMutation<ClusterInfo, Error, { name: string }>({
|
|
2261
|
+
mutationFn: async ({ name }) => {
|
|
2262
|
+
const controller = new AbortController()
|
|
2263
|
+
const timeoutId = setTimeout(() => controller.abort(), CONTEXT_SWITCH_TIMEOUT)
|
|
2264
|
+
|
|
2265
|
+
try {
|
|
2266
|
+
const response = await apiFetch(`${getApiBase()}/contexts/${encodeURIComponent(name)}`, {
|
|
2267
|
+
method: 'POST',
|
|
2268
|
+
signal: controller.signal,
|
|
2269
|
+
})
|
|
2270
|
+
clearTimeout(timeoutId)
|
|
2271
|
+
|
|
2272
|
+
if (!response.ok) {
|
|
2273
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
2274
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
2275
|
+
}
|
|
2276
|
+
return response.json()
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
clearTimeout(timeoutId)
|
|
2279
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
2280
|
+
throw new Error('Context switch timed out. The cluster may be unreachable.')
|
|
2281
|
+
}
|
|
2282
|
+
throw error
|
|
2283
|
+
}
|
|
2284
|
+
},
|
|
2285
|
+
onSuccess: () => {
|
|
2286
|
+
// Clear all query cache to ensure fresh data from new context
|
|
2287
|
+
// Using removeQueries + invalidateQueries ensures no stale data is served
|
|
2288
|
+
queryClient.removeQueries()
|
|
2289
|
+
queryClient.invalidateQueries()
|
|
2290
|
+
},
|
|
2291
|
+
onError: () => {
|
|
2292
|
+
// Invalidate contexts so the dropdown checkmark reflects the backend's
|
|
2293
|
+
// current context after a failed switch (backend has already switched
|
|
2294
|
+
// the in-memory context even though connectivity failed).
|
|
2295
|
+
queryClient.invalidateQueries({ queryKey: ['contexts'] })
|
|
2296
|
+
},
|
|
2297
|
+
})
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// ============================================================================
|
|
2301
|
+
// Image Filesystem Inspection
|
|
2302
|
+
// ============================================================================
|
|
2303
|
+
|
|
2304
|
+
import type { ImageFilesystem, ImageMetadata, WorkloadPodInfo } from '../types'
|
|
2305
|
+
|
|
2306
|
+
// Fetch image metadata (lightweight, checks if cached)
|
|
2307
|
+
export function useImageMetadata(
|
|
2308
|
+
image: string,
|
|
2309
|
+
namespace: string,
|
|
2310
|
+
podName: string,
|
|
2311
|
+
pullSecrets: string[],
|
|
2312
|
+
enabled = true
|
|
2313
|
+
) {
|
|
2314
|
+
const params = new URLSearchParams()
|
|
2315
|
+
params.set('image', image)
|
|
2316
|
+
if (namespace) params.set('namespace', namespace)
|
|
2317
|
+
if (podName) params.set('pod', podName)
|
|
2318
|
+
if (pullSecrets.length > 0) params.set('pullSecrets', pullSecrets.join(','))
|
|
2319
|
+
|
|
2320
|
+
return useQuery<ImageMetadata>({
|
|
2321
|
+
queryKey: ['image-metadata', image, namespace, podName, pullSecrets.join(',')],
|
|
2322
|
+
queryFn: () => fetchJSON(`/images/metadata?${params.toString()}`),
|
|
2323
|
+
enabled: enabled && Boolean(image),
|
|
2324
|
+
staleTime: 60000, // 1 minute - metadata is lightweight
|
|
2325
|
+
retry: false,
|
|
2326
|
+
})
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
// Fetch full image filesystem (downloads layers if not cached)
|
|
2330
|
+
export function useImageFilesystem(
|
|
2331
|
+
image: string,
|
|
2332
|
+
namespace: string,
|
|
2333
|
+
podName: string,
|
|
2334
|
+
pullSecrets: string[],
|
|
2335
|
+
enabled = true
|
|
2336
|
+
) {
|
|
2337
|
+
const params = new URLSearchParams()
|
|
2338
|
+
params.set('image', image)
|
|
2339
|
+
if (namespace) params.set('namespace', namespace)
|
|
2340
|
+
if (podName) params.set('pod', podName)
|
|
2341
|
+
if (pullSecrets.length > 0) params.set('pullSecrets', pullSecrets.join(','))
|
|
2342
|
+
|
|
2343
|
+
const shouldFetch = enabled && Boolean(image)
|
|
2344
|
+
|
|
2345
|
+
return useQuery<ImageFilesystem>({
|
|
2346
|
+
queryKey: ['image-filesystem', image, namespace, podName, pullSecrets.join(',')],
|
|
2347
|
+
// Use skipToken to completely prevent the query from running when disabled
|
|
2348
|
+
queryFn: shouldFetch
|
|
2349
|
+
? () => fetchJSON(`/images/inspect?${params.toString()}`)
|
|
2350
|
+
: skipToken,
|
|
2351
|
+
staleTime: 300000, // 5 minutes - image content doesn't change
|
|
2352
|
+
retry: false, // Don't retry on auth errors
|
|
2353
|
+
})
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// ============================================================================
|
|
2357
|
+
// Workload Logs (aggregated from all pods)
|
|
2358
|
+
// ============================================================================
|
|
2359
|
+
|
|
2360
|
+
// Response from workload pods endpoint
|
|
2361
|
+
export interface WorkloadPodsResponse {
|
|
2362
|
+
pods: WorkloadPodInfo[]
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// Response from workload logs endpoint (non-streaming)
|
|
2366
|
+
export interface WorkloadLogsResponse {
|
|
2367
|
+
pods: WorkloadPodInfo[]
|
|
2368
|
+
logs: {
|
|
2369
|
+
pod: string
|
|
2370
|
+
container: string
|
|
2371
|
+
timestamp: string
|
|
2372
|
+
content: string
|
|
2373
|
+
}[]
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// Fetch pods for a workload
|
|
2377
|
+
export function useWorkloadPods(kind: string, namespace: string, name: string) {
|
|
2378
|
+
return useQuery<WorkloadPodsResponse>({
|
|
2379
|
+
queryKey: ['workload-pods', kind, namespace, name],
|
|
2380
|
+
queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/pods`),
|
|
2381
|
+
enabled: Boolean(kind && namespace && name),
|
|
2382
|
+
staleTime: 10000, // 10 seconds - pods can change
|
|
2383
|
+
})
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// Fetch logs for a workload (non-streaming)
|
|
2387
|
+
export function useWorkloadLogs(
|
|
2388
|
+
kind: string,
|
|
2389
|
+
namespace: string,
|
|
2390
|
+
name: string,
|
|
2391
|
+
options?: {
|
|
2392
|
+
container?: string
|
|
2393
|
+
tailLines?: number
|
|
2394
|
+
sinceSeconds?: number
|
|
2395
|
+
}
|
|
2396
|
+
) {
|
|
2397
|
+
const params = new URLSearchParams()
|
|
2398
|
+
if (options?.container) params.set('container', options.container)
|
|
2399
|
+
if (options?.tailLines) params.set('tailLines', String(options.tailLines))
|
|
2400
|
+
if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
|
|
2401
|
+
const queryString = params.toString()
|
|
2402
|
+
|
|
2403
|
+
return useQuery<WorkloadLogsResponse>({
|
|
2404
|
+
queryKey: ['workload-logs', kind, namespace, name, options?.container, options?.tailLines, options?.sinceSeconds],
|
|
2405
|
+
queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/logs${queryString ? `?${queryString}` : ''}`),
|
|
2406
|
+
enabled: Boolean(kind && namespace && name),
|
|
2407
|
+
staleTime: 5000,
|
|
2408
|
+
})
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// Create SSE connection for streaming workload logs
|
|
2412
|
+
export function createWorkloadLogStream(
|
|
2413
|
+
kind: string,
|
|
2414
|
+
namespace: string,
|
|
2415
|
+
name: string,
|
|
2416
|
+
options?: {
|
|
2417
|
+
container?: string
|
|
2418
|
+
tailLines?: number
|
|
2419
|
+
sinceSeconds?: number
|
|
2420
|
+
}
|
|
2421
|
+
): EventSource {
|
|
2422
|
+
const params = new URLSearchParams()
|
|
2423
|
+
if (options?.container) params.set('container', options.container)
|
|
2424
|
+
if (options?.tailLines) params.set('tailLines', String(options.tailLines))
|
|
2425
|
+
if (options?.sinceSeconds) params.set('sinceSeconds', String(options.sinceSeconds))
|
|
2426
|
+
const queryString = params.toString()
|
|
2427
|
+
|
|
2428
|
+
return new EventSource(`${getApiBase()}/workloads/${kind}/${namespace}/${name}/logs/stream${queryString ? `?${queryString}` : ''}`, {
|
|
2429
|
+
withCredentials: getCredentialsMode() === 'include',
|
|
2430
|
+
})
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// ============================================================================
|
|
2434
|
+
// Diagnostics
|
|
2435
|
+
// ============================================================================
|
|
2436
|
+
|
|
2437
|
+
export interface DiagMetricsSourceHealth {
|
|
2438
|
+
collecting: boolean
|
|
2439
|
+
lastSuccess?: string
|
|
2440
|
+
consecutiveErrors: number
|
|
2441
|
+
lastError?: string
|
|
2442
|
+
trackedCount: number
|
|
2443
|
+
totalDataPoints: number
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
export interface DiagDropRecord {
|
|
2447
|
+
kind: string
|
|
2448
|
+
namespace: string
|
|
2449
|
+
name: string
|
|
2450
|
+
reason: string
|
|
2451
|
+
operation: string
|
|
2452
|
+
time: string
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
export interface DiagErrorEntry {
|
|
2456
|
+
time: string
|
|
2457
|
+
source: string
|
|
2458
|
+
message: string
|
|
2459
|
+
level: string
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
export interface DiagnosticsSnapshot {
|
|
2463
|
+
timestamp: string
|
|
2464
|
+
radarVersion: string
|
|
2465
|
+
goVersion: string
|
|
2466
|
+
goos: string
|
|
2467
|
+
goarch: string
|
|
2468
|
+
uptime: string
|
|
2469
|
+
uptimeSec: number
|
|
2470
|
+
|
|
2471
|
+
connection?: {
|
|
2472
|
+
state: string
|
|
2473
|
+
context: string
|
|
2474
|
+
clusterName?: string
|
|
2475
|
+
error?: string
|
|
2476
|
+
errorType?: string
|
|
2477
|
+
}
|
|
2478
|
+
kubeconfig?: {
|
|
2479
|
+
mode: '' | 'in-cluster' | 'single' | 'multi-env' | 'multi-dir'
|
|
2480
|
+
fileCount: number
|
|
2481
|
+
contextCount: number
|
|
2482
|
+
enrichedFromShell: boolean
|
|
2483
|
+
currentContextUsesExec: boolean
|
|
2484
|
+
execPluginsPresent?: string[]
|
|
2485
|
+
execPluginsMissing?: string[]
|
|
2486
|
+
}
|
|
2487
|
+
cluster?: {
|
|
2488
|
+
platform: string
|
|
2489
|
+
kubernetesVersion: string
|
|
2490
|
+
nodeCount: number
|
|
2491
|
+
namespaceCount: number
|
|
2492
|
+
inCluster: boolean
|
|
2493
|
+
}
|
|
2494
|
+
cache?: {
|
|
2495
|
+
watchedKinds: string[]
|
|
2496
|
+
totalResources: number
|
|
2497
|
+
}
|
|
2498
|
+
metrics?: {
|
|
2499
|
+
podMetrics: DiagMetricsSourceHealth
|
|
2500
|
+
nodeMetrics: DiagMetricsSourceHealth
|
|
2501
|
+
lastAttempt?: string
|
|
2502
|
+
totalCollections: number
|
|
2503
|
+
bufferSize: number
|
|
2504
|
+
pollIntervalSec: number
|
|
2505
|
+
}
|
|
2506
|
+
timeline?: {
|
|
2507
|
+
storageType: string
|
|
2508
|
+
totalEvents: number
|
|
2509
|
+
oldestEvent?: string
|
|
2510
|
+
newestEvent?: string
|
|
2511
|
+
storeErrors: number
|
|
2512
|
+
totalDrops: number
|
|
2513
|
+
}
|
|
2514
|
+
eventPipeline?: {
|
|
2515
|
+
received: Record<string, number>
|
|
2516
|
+
dropped: Record<string, number>
|
|
2517
|
+
recorded: Record<string, number>
|
|
2518
|
+
recentDrops: DiagDropRecord[]
|
|
2519
|
+
uptime: string
|
|
2520
|
+
}
|
|
2521
|
+
informers?: {
|
|
2522
|
+
typedCount: number
|
|
2523
|
+
dynamicCount: number
|
|
2524
|
+
watchedCRDs: string[]
|
|
2525
|
+
}
|
|
2526
|
+
prometheus?: {
|
|
2527
|
+
connected: boolean
|
|
2528
|
+
address?: string
|
|
2529
|
+
serviceName?: string
|
|
2530
|
+
serviceNamespace?: string
|
|
2531
|
+
}
|
|
2532
|
+
traffic?: {
|
|
2533
|
+
activeSource: string
|
|
2534
|
+
detected: string[]
|
|
2535
|
+
notDetected: string[]
|
|
2536
|
+
}
|
|
2537
|
+
permissions?: {
|
|
2538
|
+
exec: boolean
|
|
2539
|
+
logs: boolean
|
|
2540
|
+
portForward: boolean
|
|
2541
|
+
secrets: boolean
|
|
2542
|
+
helmWrite: boolean
|
|
2543
|
+
namespaceScoped: boolean
|
|
2544
|
+
namespace?: string
|
|
2545
|
+
restricted?: string[]
|
|
2546
|
+
}
|
|
2547
|
+
apiDiscovery?: {
|
|
2548
|
+
totalResources: number
|
|
2549
|
+
crdCount: number
|
|
2550
|
+
lastRefresh?: string
|
|
2551
|
+
}
|
|
2552
|
+
sse?: {
|
|
2553
|
+
connectedClients: number
|
|
2554
|
+
}
|
|
2555
|
+
runtime?: {
|
|
2556
|
+
heapMB: number
|
|
2557
|
+
heapObjectsK: number
|
|
2558
|
+
goroutines: number
|
|
2559
|
+
numCPU: number
|
|
2560
|
+
}
|
|
2561
|
+
config?: {
|
|
2562
|
+
port: number
|
|
2563
|
+
devMode: boolean
|
|
2564
|
+
namespace?: string
|
|
2565
|
+
timelineStorage: string
|
|
2566
|
+
historyLimit: number
|
|
2567
|
+
debugEvents: boolean
|
|
2568
|
+
mcpEnabled: boolean
|
|
2569
|
+
hasPrometheusURL: boolean
|
|
2570
|
+
}
|
|
2571
|
+
recentErrors?: DiagErrorEntry[]
|
|
2572
|
+
totalErrorsRecorded?: number
|
|
2573
|
+
errors?: string[]
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
export function useDiagnostics(enabled: boolean) {
|
|
2577
|
+
return useQuery<DiagnosticsSnapshot>({
|
|
2578
|
+
queryKey: ['diagnostics'],
|
|
2579
|
+
queryFn: enabled ? () => fetchJSON('/diagnostics') : skipToken,
|
|
2580
|
+
staleTime: 0,
|
|
2581
|
+
gcTime: 0,
|
|
2582
|
+
})
|
|
2583
|
+
}
|