@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.
Files changed (251) hide show
  1. package/README.md +67 -0
  2. package/package.json +80 -0
  3. package/src/App.tsx +1538 -0
  4. package/src/RadarApp.tsx +145 -0
  5. package/src/api/apiResources.ts +28 -0
  6. package/src/api/client.ts +2583 -0
  7. package/src/api/config.ts +116 -0
  8. package/src/api/traffic.ts +139 -0
  9. package/src/components/ConnectionErrorView.tsx +272 -0
  10. package/src/components/ContextSwitcher.tsx +481 -0
  11. package/src/components/DebugOverlay.tsx +94 -0
  12. package/src/components/UserMenu.tsx +87 -0
  13. package/src/components/audit/AuditSettingsDialog.tsx +162 -0
  14. package/src/components/audit/AuditView.tsx +123 -0
  15. package/src/components/cost/CostTrendChart.tsx +388 -0
  16. package/src/components/cost/CostView.tsx +545 -0
  17. package/src/components/dock/BottomDock.tsx +96 -0
  18. package/src/components/dock/DockContext.tsx +11 -0
  19. package/src/components/dock/LocalTerminalTab.tsx +22 -0
  20. package/src/components/dock/LogsTab.tsx +26 -0
  21. package/src/components/dock/NodeTerminalTab.tsx +50 -0
  22. package/src/components/dock/TerminalTab.tsx +42 -0
  23. package/src/components/dock/TrafficFlowListTab.tsx +18 -0
  24. package/src/components/dock/WorkloadLogsTab.tsx +23 -0
  25. package/src/components/dock/index.ts +2 -0
  26. package/src/components/gitops/GitOpsActions.tsx +1 -0
  27. package/src/components/gitops/GitOpsStatusBadge.tsx +1 -0
  28. package/src/components/gitops/ManagedResourcesList.tsx +1 -0
  29. package/src/components/gitops/SyncCountdown.tsx +1 -0
  30. package/src/components/gitops/index.ts +4 -0
  31. package/src/components/helm/ChartBrowser.tsx +580 -0
  32. package/src/components/helm/HelmReleaseDrawer.tsx +774 -0
  33. package/src/components/helm/HelmView.tsx +475 -0
  34. package/src/components/helm/InstallWizard.tsx +1060 -0
  35. package/src/components/helm/ManifestDiffViewer.tsx +91 -0
  36. package/src/components/helm/ManifestViewer.tsx +61 -0
  37. package/src/components/helm/OwnedResources.tsx +465 -0
  38. package/src/components/helm/RevisionHistory.tsx +167 -0
  39. package/src/components/helm/ValuesDiffPreview.tsx +190 -0
  40. package/src/components/helm/ValuesViewer.tsx +365 -0
  41. package/src/components/helm/helm-utils.ts +37 -0
  42. package/src/components/home/ActivitySummary.tsx +262 -0
  43. package/src/components/home/CertificateHealthCard.tsx +105 -0
  44. package/src/components/home/ClusterHealthCard.tsx +483 -0
  45. package/src/components/home/CostCard.tsx +112 -0
  46. package/src/components/home/HealthRing.tsx +1 -0
  47. package/src/components/home/HelmSummary.tsx +129 -0
  48. package/src/components/home/HomeView.tsx +224 -0
  49. package/src/components/home/MCPSetupDialog.tsx +417 -0
  50. package/src/components/home/NetworkPolicyCoverageCard.tsx +109 -0
  51. package/src/components/home/TopologyPreview.tsx +219 -0
  52. package/src/components/home/TrafficSummary.tsx +154 -0
  53. package/src/components/logs/JsonLogLine.tsx +1 -0
  54. package/src/components/logs/LogCore.tsx +2 -0
  55. package/src/components/logs/LogsViewer.tsx +44 -0
  56. package/src/components/logs/WorkloadLogsViewer.tsx +40 -0
  57. package/src/components/logs/useLogBuffer.ts +2 -0
  58. package/src/components/logs/useLogSearch.ts +1 -0
  59. package/src/components/portforward/PortForwardButton.tsx +375 -0
  60. package/src/components/portforward/PortForwardManager.tsx +871 -0
  61. package/src/components/resource/PrometheusCharts.tsx +687 -0
  62. package/src/components/resource-drawer/ResourceDrawer.tsx +214 -0
  63. package/src/components/resources/ImageFilesystemModal.tsx +745 -0
  64. package/src/components/resources/PodFilesystemModal.tsx +407 -0
  65. package/src/components/resources/ResourceDetailDrawer.tsx +43 -0
  66. package/src/components/resources/ResourcesView.tsx +190 -0
  67. package/src/components/resources/drawer-components.tsx +1 -0
  68. package/src/components/resources/file-browser-utils.ts +35 -0
  69. package/src/components/resources/renderers/AlertRenderer.tsx +1 -0
  70. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +17 -0
  71. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +1 -0
  72. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -0
  73. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -0
  74. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +1 -0
  75. package/src/components/resources/renderers/CertificateRenderer.tsx +1 -0
  76. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +1 -0
  77. package/src/components/resources/renderers/ChallengeRenderer.tsx +1 -0
  78. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +1 -0
  79. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +1 -0
  80. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +1 -0
  81. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +1 -0
  82. package/src/components/resources/renderers/ConfigMapRenderer.tsx +1 -0
  83. package/src/components/resources/renderers/CronJobRenderer.tsx +1 -0
  84. package/src/components/resources/renderers/EventRenderer.tsx +1 -0
  85. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +1 -0
  86. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +1 -0
  87. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -0
  88. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +1 -0
  89. package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -0
  90. package/src/components/resources/renderers/GatewayRenderer.tsx +1 -0
  91. package/src/components/resources/renderers/GenericRenderer.tsx +1 -0
  92. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +1 -0
  93. package/src/components/resources/renderers/HPARenderer.tsx +1 -0
  94. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +1 -0
  95. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -0
  96. package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -0
  97. package/src/components/resources/renderers/IngressRenderer.tsx +1 -0
  98. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +1 -0
  99. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -0
  100. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +1 -0
  101. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +1 -0
  102. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +1 -0
  103. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +1 -0
  104. package/src/components/resources/renderers/JobRenderer.tsx +1 -0
  105. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +1 -0
  106. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +1 -0
  107. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +1 -0
  108. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +1 -0
  109. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +1 -0
  110. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +1 -0
  111. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -0
  112. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +1 -0
  113. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +1 -0
  114. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -0
  116. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -0
  117. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -0
  118. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -0
  119. package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -0
  120. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +1 -0
  121. package/src/components/resources/renderers/LeaseRenderer.tsx +1 -0
  122. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +1 -0
  123. package/src/components/resources/renderers/NodeRenderer.tsx +44 -0
  124. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +1 -0
  125. package/src/components/resources/renderers/OrderRenderer.tsx +1 -0
  126. package/src/components/resources/renderers/PVCRenderer.tsx +1 -0
  127. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -0
  128. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -0
  129. package/src/components/resources/renderers/PodMonitorRenderer.tsx +1 -0
  130. package/src/components/resources/renderers/PodRenderer.tsx +94 -0
  131. package/src/components/resources/renderers/PriorityClassRenderer.tsx +1 -0
  132. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +1 -0
  133. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +1 -0
  134. package/src/components/resources/renderers/RoleBindingRenderer.tsx +1 -0
  135. package/src/components/resources/renderers/RoleRenderer.tsx +1 -0
  136. package/src/components/resources/renderers/RolloutRenderer.tsx +1 -0
  137. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +1 -0
  138. package/src/components/resources/renderers/SbomReportRenderer.tsx +1 -0
  139. package/src/components/resources/renderers/SealedSecretRenderer.tsx +1 -0
  140. package/src/components/resources/renderers/SecretRenderer.tsx +1 -0
  141. package/src/components/resources/renderers/SecretStoreRenderer.tsx +1 -0
  142. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -0
  143. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +1 -0
  144. package/src/components/resources/renderers/ServiceRenderer.tsx +26 -0
  145. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -0
  146. package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -0
  147. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -0
  148. package/src/components/resources/renderers/VPARenderer.tsx +1 -0
  149. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +1 -0
  150. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -0
  151. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +1 -0
  152. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +1 -0
  153. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -0
  154. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -0
  155. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +1 -0
  156. package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -0
  157. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +1 -0
  158. package/src/components/resources/renderers/WorkloadRenderer.tsx +52 -0
  159. package/src/components/resources/renderers/argo-cells.tsx +1 -0
  160. package/src/components/resources/renderers/certmanager-cells.tsx +1 -0
  161. package/src/components/resources/renderers/cnpg-cells.tsx +1 -0
  162. package/src/components/resources/renderers/eso-cells.tsx +1 -0
  163. package/src/components/resources/renderers/flux-cells.tsx +1 -0
  164. package/src/components/resources/renderers/index.ts +91 -0
  165. package/src/components/resources/renderers/istio-cells.tsx +1 -0
  166. package/src/components/resources/renderers/karpenter-cells.tsx +1 -0
  167. package/src/components/resources/renderers/keda-cells.tsx +1 -0
  168. package/src/components/resources/renderers/knative-cells.tsx +1 -0
  169. package/src/components/resources/renderers/kyverno-cells.tsx +1 -0
  170. package/src/components/resources/renderers/prometheus-cells.tsx +1 -0
  171. package/src/components/resources/renderers/traefik-cells.tsx +1 -0
  172. package/src/components/resources/renderers/trivy-cells.tsx +1 -0
  173. package/src/components/resources/renderers/trivy-shared.tsx +1 -0
  174. package/src/components/resources/renderers/velero-cells.tsx +1 -0
  175. package/src/components/resources/resource-utils-argo.ts +2 -0
  176. package/src/components/resources/resource-utils-certmanager.ts +2 -0
  177. package/src/components/resources/resource-utils-cnpg.ts +2 -0
  178. package/src/components/resources/resource-utils-eso.ts +2 -0
  179. package/src/components/resources/resource-utils-flux.ts +2 -0
  180. package/src/components/resources/resource-utils-istio.ts +2 -0
  181. package/src/components/resources/resource-utils-karpenter.ts +2 -0
  182. package/src/components/resources/resource-utils-keda.ts +2 -0
  183. package/src/components/resources/resource-utils-knative.ts +2 -0
  184. package/src/components/resources/resource-utils-kyverno.ts +2 -0
  185. package/src/components/resources/resource-utils-prometheus.ts +2 -0
  186. package/src/components/resources/resource-utils-traefik.ts +1 -0
  187. package/src/components/resources/resource-utils-trivy.ts +2 -0
  188. package/src/components/resources/resource-utils-velero.ts +2 -0
  189. package/src/components/resources/resource-utils.ts +5 -0
  190. package/src/components/settings/SettingsDialog.tsx +537 -0
  191. package/src/components/shared/CreateResourceDialog.tsx +17 -0
  192. package/src/components/shared/EditableYamlView.tsx +24 -0
  193. package/src/components/shared/LargeClusterNamespacePicker.tsx +70 -0
  194. package/src/components/shared/ResourceRendererDispatch.tsx +31 -0
  195. package/src/components/timeline/DiffViewer.tsx +1 -0
  196. package/src/components/timeline/TimelineList.tsx +69 -0
  197. package/src/components/timeline/TimelineSwimlanes.tsx +1308 -0
  198. package/src/components/timeline/TimelineView.tsx +157 -0
  199. package/src/components/timeline/shared.tsx +1 -0
  200. package/src/components/traffic/TrafficFilterSidebar.tsx +571 -0
  201. package/src/components/traffic/TrafficFlowList.tsx +415 -0
  202. package/src/components/traffic/TrafficFlowListContext.tsx +68 -0
  203. package/src/components/traffic/TrafficGraph.tsx +1546 -0
  204. package/src/components/traffic/TrafficView.tsx +1213 -0
  205. package/src/components/traffic/TrafficWizard.tsx +386 -0
  206. package/src/components/traffic/index.ts +3 -0
  207. package/src/components/ui/CodeViewer.tsx +8 -0
  208. package/src/components/ui/CommandPalette.tsx +460 -0
  209. package/src/components/ui/ConfirmDialog.tsx +1 -0
  210. package/src/components/ui/DiagnosticsOverlay.tsx +619 -0
  211. package/src/components/ui/ErrorBoundary.tsx +46 -0
  212. package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -0
  213. package/src/components/ui/Markdown.tsx +108 -0
  214. package/src/components/ui/MetricsChart.tsx +1 -0
  215. package/src/components/ui/NamespaceSelector.tsx +436 -0
  216. package/src/components/ui/ResourceBar.tsx +1 -0
  217. package/src/components/ui/ShortcutHelpOverlay.tsx +301 -0
  218. package/src/components/ui/Toast.tsx +1 -0
  219. package/src/components/ui/Tooltip.tsx +1 -0
  220. package/src/components/ui/UpdateNotification.tsx +299 -0
  221. package/src/components/ui/YamlEditor.tsx +1 -0
  222. package/src/components/workload/WorkloadView.tsx +532 -0
  223. package/src/context/ConnectionContext.tsx +173 -0
  224. package/src/context/ContextSwitchContext.tsx +56 -0
  225. package/src/context/NavCustomization.tsx +62 -0
  226. package/src/context/ThemeContext.tsx +97 -0
  227. package/src/contexts/CapabilitiesContext.tsx +130 -0
  228. package/src/hooks/useAnimatedUnmount.ts +1 -0
  229. package/src/hooks/useDesktopDownload.ts +41 -0
  230. package/src/hooks/useEventSource.ts +262 -0
  231. package/src/hooks/useFavorites.ts +69 -0
  232. package/src/hooks/useKeyboardShortcuts.tsx +7 -0
  233. package/src/hooks/useRefreshAnimation.ts +1 -0
  234. package/src/index.css +243 -0
  235. package/src/index.ts +17 -0
  236. package/src/main.tsx +158 -0
  237. package/src/types/gitops.ts +2 -0
  238. package/src/types.ts +3 -0
  239. package/src/utils/animation.ts +2 -0
  240. package/src/utils/badge-colors.ts +2 -0
  241. package/src/utils/context-name.ts +2 -0
  242. package/src/utils/desktop-download.ts +66 -0
  243. package/src/utils/desktop-open-folder.ts +21 -0
  244. package/src/utils/format.ts +2 -0
  245. package/src/utils/log-format.ts +12 -0
  246. package/src/utils/navigation.ts +23 -0
  247. package/src/utils/resource-hierarchy.ts +2 -0
  248. package/src/utils/resource-icons.ts +2 -0
  249. package/src/utils/skeleton-yaml.ts +2 -0
  250. package/src/utils/traffic-colors.ts +54 -0
  251. 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
+ }