@skyhook-io/radar-app 1.8.5 → 1.8.7

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 (78) hide show
  1. package/README.md +7 -1
  2. package/package.json +11 -9
  3. package/src/App.tsx +229 -183
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.delta.test.ts +89 -0
  6. package/src/api/client.deltaSync.test.ts +216 -0
  7. package/src/api/client.metrics.test.ts +106 -0
  8. package/src/api/client.ts +377 -48
  9. package/src/api/diagnose.ts +288 -0
  10. package/src/api/timelineSource.test.ts +217 -0
  11. package/src/api/timelineSource.ts +580 -0
  12. package/src/components/ConnectionErrorView.tsx +174 -70
  13. package/src/components/ContextSwitcher.tsx +13 -5
  14. package/src/components/applications/ApplicationsView.tsx +336 -32
  15. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  16. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  17. package/src/components/diagnose/AISettings.tsx +121 -0
  18. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  19. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  20. package/src/components/diagnose/Home.tsx +163 -0
  21. package/src/components/diagnose/InvestigationView.tsx +604 -0
  22. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  23. package/src/components/diagnose/launch.ts +65 -0
  24. package/src/components/diagnose/parts.tsx +1689 -0
  25. package/src/components/dock/BottomDock.tsx +2 -3
  26. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  27. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  28. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  29. package/src/components/execution/batch-run-actions.test.ts +48 -0
  30. package/src/components/execution/batch-run-actions.ts +24 -0
  31. package/src/components/execution/batch-timeline.test.ts +57 -0
  32. package/src/components/execution/batch-timeline.ts +46 -0
  33. package/src/components/execution/execution-definition.test.ts +208 -0
  34. package/src/components/execution/execution-definition.ts +245 -0
  35. package/src/components/helm/ChartBrowser.tsx +2 -3
  36. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  37. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  38. package/src/components/helm/HelmView.tsx +2 -3
  39. package/src/components/helm/InstallWizard.tsx +3 -5
  40. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  41. package/src/components/helm/ValuesDiffPreview.tsx +15 -4
  42. package/src/components/home/HomeView.tsx +22 -18
  43. package/src/components/home/MCPSetupDialog.tsx +1 -1
  44. package/src/components/home/mcpToolCatalog.ts +2 -2
  45. package/src/components/issues/IssuesPane.tsx +9 -1
  46. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  47. package/src/components/portforward/PortForwardButton.tsx +2 -2
  48. package/src/components/portforward/PortForwardManager.tsx +19 -14
  49. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  50. package/src/components/resource/RightsizingStrip.tsx +0 -3
  51. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  52. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  53. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  54. package/src/components/resources/ResourcesView.tsx +13 -4
  55. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  56. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  57. package/src/components/resources/renderers/PodRenderer.tsx +10 -4
  58. package/src/components/resources/renderers/index.ts +1 -0
  59. package/src/components/settings/SettingsDialog.tsx +80 -23
  60. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  61. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  62. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  63. package/src/components/timeline/TimelineList.tsx +51 -10
  64. package/src/components/timeline/TimelineView.tsx +701 -15
  65. package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
  66. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  67. package/src/components/traffic/TrafficView.tsx +8 -4
  68. package/src/components/ui/CommandPalette.tsx +2 -2
  69. package/src/components/workload/WorkloadView.tsx +319 -35
  70. package/src/context/ConnectionContext.tsx +109 -11
  71. package/src/context/DiagnoseCustomization.tsx +42 -0
  72. package/src/context/TimelineSource.tsx +50 -0
  73. package/src/hooks/useClusterLoadState.ts +73 -0
  74. package/src/hooks/useEventSource.ts +6 -0
  75. package/src/index.css +157 -0
  76. package/src/index.ts +12 -0
  77. package/src/types/clusterLoadState.ts +33 -0
  78. package/src/utils/navigation.ts +10 -0
package/src/api/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef } from 'react'
2
- import type { AppRow } from '@skyhook-io/k8s-ui'
2
+ import type { AppHistory, AppRow } from '@skyhook-io/k8s-ui'
3
3
  import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
4
4
  import { showApiError, showApiSuccess } from '../components/ui/Toast'
5
5
  import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
@@ -103,6 +103,47 @@ export function isForbiddenError(error: unknown): boolean {
103
103
  return error instanceof ApiError && error.status === 403
104
104
  }
105
105
 
106
+ const METRICS_API_GROUP_TOKENS = ['metrics', 'k8s', 'io'] as const
107
+
108
+ function mentionsMetricsAPIGroup(message: string): boolean {
109
+ const tokens = message.split(/[^a-z0-9]+/).filter(Boolean)
110
+ return tokens.some((token, index) => (
111
+ token === METRICS_API_GROUP_TOKENS[0] &&
112
+ tokens[index + 1] === METRICS_API_GROUP_TOKENS[1] &&
113
+ tokens[index + 2] === METRICS_API_GROUP_TOKENS[2]
114
+ ))
115
+ }
116
+
117
+ function hasMetricsUnavailablePhrase(message: string): boolean {
118
+ return (
119
+ message.includes('may not be installed') ||
120
+ message.includes('not found') ||
121
+ message.includes('could not find the requested resource') ||
122
+ message.includes('no matches for kind') ||
123
+ message.includes('no resource matches') ||
124
+ message.includes('no metrics known') ||
125
+ message.includes('not available') ||
126
+ message.includes('unable to fetch metrics') ||
127
+ message.includes('currently unable to handle the request')
128
+ )
129
+ }
130
+
131
+ export function isMetricsUnavailableError(error: unknown): boolean {
132
+ if (!(error instanceof ApiError)) return false
133
+ if (error.status !== 404 && error.status !== 500) return false
134
+ return [error.message, error.data?.error].some((message) => {
135
+ if (typeof message !== 'string') return false
136
+ const normalized = message.toLowerCase()
137
+ const hasMetricsSignal = (
138
+ normalized.includes('metrics-server') ||
139
+ mentionsMetricsAPIGroup(normalized) ||
140
+ normalized.includes('pod metrics') ||
141
+ normalized.includes('node metrics')
142
+ )
143
+ return hasMetricsSignal && hasMetricsUnavailablePhrase(normalized)
144
+ })
145
+ }
146
+
106
147
  export async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
107
148
  const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined)
108
149
  if (!response.ok) {
@@ -330,11 +371,12 @@ export interface DashboardCRDsResponse {
330
371
  topCRDs: DashboardCRDCount[]
331
372
  }
332
373
 
333
- export function useDashboard(namespaces: string[] = []) {
374
+ export function useDashboard(namespaces: string[] = [], options?: { enabled?: boolean }) {
334
375
  const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
335
376
  return useQuery<DashboardResponse>({
336
377
  queryKey: ['dashboard', namespaces],
337
378
  queryFn: () => fetchJSON(`/dashboard${params}`),
379
+ enabled: options?.enabled ?? true,
338
380
  staleTime: 15000, // 15 seconds
339
381
  refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
340
382
  })
@@ -941,34 +983,55 @@ export function useNamespaces() {
941
983
  }
942
984
 
943
985
  // Topology (for manual refresh)
944
- export function useTopology(namespaces: string[], viewMode: string = 'resources', options?: { enabled?: boolean }) {
986
+ export function useTopology(namespaces: string[], viewMode: string = 'resources', options?: { enabled?: boolean; includeReplicaSets?: boolean; refetchInterval?: number | false }) {
945
987
  const params = new URLSearchParams()
946
988
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
947
989
  if (viewMode) params.set('view', viewMode)
990
+ if (options?.includeReplicaSets) params.set('includeReplicaSets', 'true')
948
991
  const queryString = params.toString()
949
992
 
950
993
  return useQuery<Topology>({
951
- queryKey: ['topology', namespaces, viewMode],
994
+ queryKey: ['topology', namespaces, viewMode, options?.includeReplicaSets ?? false],
952
995
  queryFn: () => fetchJSON(`/topology${queryString ? `?${queryString}` : ''}`),
953
996
  staleTime: 5000, // 5 seconds
954
997
  enabled: options?.enabled !== false,
998
+ refetchInterval: options?.refetchInterval,
955
999
  })
956
1000
  }
957
1001
 
958
- export function useApplications(namespaces: string[]) {
1002
+ export function useApplications(namespaces: string[], options?: { enabled?: boolean }) {
959
1003
  const params = new URLSearchParams()
960
1004
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
961
1005
  const queryString = params.toString()
962
1006
 
1007
+ const enabled = options?.enabled !== false
963
1008
  return useQuery<{ applications: AppRow[] }>({
964
1009
  queryKey: ['applications', namespaces],
965
1010
  queryFn: () => fetchJSON(`/applications${queryString ? `?${queryString}` : ''}`),
966
1011
  staleTime: 30_000,
1012
+ // Only poll while a consumer needs the index; gated off it must not keep the
1013
+ // background refetch alive.
1014
+ enabled,
1015
+ refetchInterval: enabled ? APPLICATIONS_REFRESH_INTERVAL_MS : false,
1016
+ })
1017
+ }
1018
+
1019
+ export function useApplicationHistory(appKey: string | undefined, namespaces: string[], options?: { enabled?: boolean }) {
1020
+ const params = new URLSearchParams()
1021
+ if (appKey) params.set('app', appKey)
1022
+ if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
1023
+ const queryString = params.toString()
1024
+
1025
+ return useQuery<AppHistory>({
1026
+ queryKey: ['application-history', appKey, namespaces],
1027
+ queryFn: appKey ? () => fetchJSON(`/applications/history?${queryString}`) : skipToken,
1028
+ enabled: Boolean(appKey) && (options?.enabled ?? true),
1029
+ staleTime: 15_000,
967
1030
  refetchInterval: APPLICATIONS_REFRESH_INTERVAL_MS,
968
1031
  })
969
1032
  }
970
1033
 
971
- export function useGitOpsTree(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = []) {
1034
+ export function useGitOpsTree(kind: string, namespace: string, name: string, group?: string, namespaces: string[] = [], options?: { enabled?: boolean }) {
972
1035
  const ns = namespace || '_'
973
1036
  const params = new URLSearchParams()
974
1037
  if (group) params.set('group', group)
@@ -978,7 +1041,7 @@ export function useGitOpsTree(kind: string, namespace: string, name: string, gro
978
1041
  return useQuery<GitOpsResourceTree>({
979
1042
  queryKey: ['gitops-tree', kind, namespace, name, group, namespaces],
980
1043
  queryFn: () => fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
981
- enabled: Boolean(kind && name),
1044
+ enabled: Boolean(kind && name) && (options?.enabled ?? true),
982
1045
  staleTime: 5000,
983
1046
  })
984
1047
  }
@@ -1010,7 +1073,7 @@ export function useGitOpsInsights(kind: string, namespace: string, name: string,
1010
1073
 
1011
1074
  // Generic resource fetching - returns resource with relationships
1012
1075
  // Uses '_' as placeholder for cluster-scoped resources (empty namespace)
1013
- export function useResource<T>(kind: string, namespace: string, name: string, group?: string) {
1076
+ export function useResource<T>(kind: string, namespace: string, name: string, group?: string, options?: { enabled?: boolean; refetchInterval?: number | false }) {
1014
1077
  // For cluster-scoped resources, use '_' as namespace placeholder
1015
1078
  const ns = namespace || '_'
1016
1079
  const params = new URLSearchParams()
@@ -1020,7 +1083,8 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
1020
1083
  const query = useQuery<ResourceWithRelationships<T>>({
1021
1084
  queryKey: ['resource', kind, namespace, name, group],
1022
1085
  queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1023
- enabled: Boolean(kind && name), // namespace can be empty for cluster-scoped resources
1086
+ enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
1087
+ refetchInterval: options?.refetchInterval,
1024
1088
  })
1025
1089
 
1026
1090
  // Extract resource and relationships from the response
@@ -1071,7 +1135,10 @@ export function useResources<T>(
1071
1135
  // Timeline changes (unified view of changes + K8s events)
1072
1136
  export interface UseChangesOptions {
1073
1137
  namespaces?: string[]
1074
- kind?: string
1138
+ // Kind filter. The server narrows to a single kind (tighter result caps), so
1139
+ // exactly one selected kind is pushed server-side; a multi-kind selection
1140
+ // fetches unfiltered and is narrowed client-side by the caller.
1141
+ kinds?: string[]
1075
1142
  timeRange?: TimeRange
1076
1143
  filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
1077
1144
  includeK8sEvents?: boolean
@@ -1079,6 +1146,122 @@ export interface UseChangesOptions {
1079
1146
  includeDeleted?: boolean
1080
1147
  limit?: number
1081
1148
  enabled?: boolean
1149
+ // Cursor-aware refetches: after the first full load, refetches ask the
1150
+ // server only for events that arrived after the highest seq already cached
1151
+ // and merge them in, instead of re-pulling the whole ring. Intended for the
1152
+ // timeline's full-ring (10k) query, where every SSE nudge would otherwise
1153
+ // re-transfer megabytes for a handful of new events.
1154
+ deltaSync?: boolean
1155
+ }
1156
+
1157
+ // The store epoch guards delta cursors: a restarted store restarts seq
1158
+ // numbering, so an epoch change forces a full resync. A periodic full resync
1159
+ // also runs as anti-entropy for anything a dropped SSE connection or a
1160
+ // server-side eviction could leave behind in the cached copy.
1161
+ const FULL_RESYNC_MS = 5 * 60_000
1162
+
1163
+ export interface ChangesDeltaMeta {
1164
+ epoch: string
1165
+ lastFullMs: number
1166
+ // Highest seq observed in ANY response for this query — not just what
1167
+ // survived the cap. A delta event older than everything cached gets capped
1168
+ // out of the merge; deriving the cursor from cached rows alone would then
1169
+ // re-request that same event on every refetch until the next full resync.
1170
+ highWaterSeq: number
1171
+ }
1172
+ const changesDeltaMeta = new Map<string, ChangesDeltaMeta>()
1173
+
1174
+ // The since_seq cursor for the next refetch, or 0 for a full fetch. Delta
1175
+ // requires an epoch-stamped prior full load, a cached page to merge into, and
1176
+ // the anti-entropy full resync not being due.
1177
+ export function deltaFetchCursor(
1178
+ meta: ChangesDeltaMeta | undefined,
1179
+ cached: TimelineEvent[] | undefined,
1180
+ nowMs: number,
1181
+ ): number {
1182
+ if (!meta?.epoch || !cached) return 0
1183
+ if (nowMs - meta.lastFullMs > FULL_RESYNC_MS) return 0
1184
+ return Math.max(meta.highWaterSeq, maxEventSeq(cached))
1185
+ }
1186
+
1187
+ async function fetchChangesPage(
1188
+ path: string,
1189
+ signal?: AbortSignal,
1190
+ ): Promise<{ events: TimelineEvent[]; epoch: string; maxSeq: number }> {
1191
+ const response = await apiFetch(`${getApiBase()}${path}`, signal ? { signal } : undefined)
1192
+ if (!response.ok) {
1193
+ const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
1194
+ throw new ApiError(errorData.error || `HTTP ${response.status}`, response.status, errorData)
1195
+ }
1196
+ const events = (await response.json()) as TimelineEvent[]
1197
+ // maxSeq is the page's frontier computed before the server's
1198
+ // cluster-scoped-RBAC filter — rows dropped THERE still advance the cursor.
1199
+ // (Rows dropped by content filters inside the store query do not; see the
1200
+ // known limitation on the server's handleChanges.)
1201
+ const maxSeq = Number(response.headers.get('X-Radar-Timeline-Max-Seq') ?? '0') || 0
1202
+ return { events, epoch: response.headers.get('X-Radar-Timeline-Epoch') ?? '', maxSeq }
1203
+ }
1204
+
1205
+ // Highest store-assigned arrival number in the cached page — the delta cursor.
1206
+ export function maxEventSeq(events: TimelineEvent[]): number {
1207
+ let max = 0
1208
+ for (const event of events) {
1209
+ if (event.seq && event.seq > max) max = event.seq
1210
+ }
1211
+ return max
1212
+ }
1213
+
1214
+ // Merge a delta page into the cached page: a delta row replaces its cached id
1215
+ // (a K8s Event count bump re-arrives under the same id), new ids are added,
1216
+ // order stays newest-first (arrival number breaks timestamp ties), and the
1217
+ // result is capped to the query's limit by dropping the oldest.
1218
+ export function mergeDeltaEvents(
1219
+ prev: TimelineEvent[],
1220
+ delta: TimelineEvent[],
1221
+ cap: number,
1222
+ ): TimelineEvent[] {
1223
+ if (delta.length === 0) return prev
1224
+ const replaced = new Set(delta.map((event) => event.id))
1225
+ const merged = [...delta, ...prev.filter((event) => !replaced.has(event.id))]
1226
+ merged.sort((a, b) => {
1227
+ const byTime = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
1228
+ if (byTime !== 0) return byTime
1229
+ return (b.seq ?? 0) - (a.seq ?? 0)
1230
+ })
1231
+ return merged.length > cap ? merged.slice(0, cap) : merged
1232
+ }
1233
+
1234
+ // Delta-sync orchestration for useChanges, extracted so the
1235
+ // full-fetch → delta-poll → epoch-mismatch-resync contract is exercisable
1236
+ // without a React render. State is passed in explicitly — the cached page and
1237
+ // the shared meta store — rather than closed over from module scope, so a
1238
+ // caller (and a test) drives it with fresh state each invocation.
1239
+ export async function runDeltaSyncFetch(deps: {
1240
+ path: string
1241
+ queryString: string
1242
+ limit: number
1243
+ metaKey: string
1244
+ cached: TimelineEvent[] | undefined
1245
+ metaStore: Map<string, ChangesDeltaMeta>
1246
+ now: number
1247
+ signal?: AbortSignal
1248
+ }): Promise<TimelineEvent[]> {
1249
+ const { path, queryString, limit, metaKey, cached, metaStore, now, signal } = deps
1250
+ const meta = metaStore.get(metaKey)
1251
+ const cursor = deltaFetchCursor(meta, cached, now)
1252
+ if (cursor > 0) {
1253
+ const delta = await fetchChangesPage(`${path}${queryString ? '&' : '?'}since_seq=${cursor}`, signal)
1254
+ if (delta.epoch && delta.epoch === meta!.epoch) {
1255
+ meta!.highWaterSeq = Math.max(meta!.highWaterSeq, delta.maxSeq, maxEventSeq(delta.events))
1256
+ // Returning the cached reference on an empty delta skips re-renders.
1257
+ return delta.events.length ? mergeDeltaEvents(cached!, delta.events, limit) : cached!
1258
+ }
1259
+ // Epoch changed — the store restarted and seq numbering reset, so the
1260
+ // cursor is meaningless. Fall through to a full resync.
1261
+ }
1262
+ const full = await fetchChangesPage(path, signal)
1263
+ metaStore.set(metaKey, { epoch: full.epoch, lastFullMs: now, highWaterSeq: Math.max(full.maxSeq, maxEventSeq(full.events)) })
1264
+ return full.events
1082
1265
  }
1083
1266
 
1084
1267
  function getTimeRangeDate(range: TimeRange): Date | null {
@@ -1095,17 +1278,26 @@ function getTimeRangeDate(range: TimeRange): Date | null {
1095
1278
  return new Date(now.getTime() - 6 * 60 * 60 * 1000)
1096
1279
  case '24h':
1097
1280
  return new Date(now.getTime() - 24 * 60 * 60 * 1000)
1281
+ case '7d':
1282
+ return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
1283
+ case '30d':
1284
+ return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
1098
1285
  default:
1099
1286
  return null
1100
1287
  }
1101
1288
  }
1102
1289
 
1103
1290
  export function useChanges(options: UseChangesOptions = {}) {
1104
- const { namespaces = [], kind, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, includeDeleted = true, limit = 200, enabled = true } = options
1291
+ const { namespaces = [], kinds, timeRange = '1h', filter = 'all', includeK8sEvents = true, includeManaged = false, includeDeleted = true, limit = 200, enabled = true, deltaSync = false } = options
1292
+ const queryClient = useQueryClient()
1293
+
1294
+ // Only a single-kind selection narrows the server query; a multi-kind
1295
+ // selection is filtered client-side so the server cap isn't spent on one kind.
1296
+ const serverKind = kinds && kinds.length === 1 ? kinds[0] : undefined
1105
1297
 
1106
1298
  const params = new URLSearchParams()
1107
1299
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
1108
- if (kind) params.set('kind', kind)
1300
+ if (serverKind) params.set('kind', serverKind)
1109
1301
  if (filter) params.set('filter', filter)
1110
1302
  if (!includeK8sEvents) params.set('include_k8s_events', 'false')
1111
1303
  if (includeManaged) params.set('include_managed', 'true')
@@ -1118,12 +1310,20 @@ export function useChanges(options: UseChangesOptions = {}) {
1118
1310
  }
1119
1311
 
1120
1312
  const queryString = params.toString()
1313
+ const path = `/changes${queryString ? `?${queryString}` : ''}`
1314
+ const queryKey = ['changes', namespaces, serverKind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit]
1121
1315
 
1122
1316
  return useQuery<TimelineEvent[]>({
1123
- queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
1124
- queryFn: () => fetchJSON(`/changes${queryString ? `?${queryString}` : ''}`),
1317
+ queryKey,
1318
+ queryFn: async ({ signal }) => {
1319
+ if (!deltaSync) return fetchJSON(path, signal)
1320
+
1321
+ const metaKey = JSON.stringify(queryKey)
1322
+ const cached = queryClient.getQueryData<TimelineEvent[]>(queryKey)
1323
+ return runDeltaSyncFetch({ path, queryString, limit, metaKey, cached, metaStore: changesDeltaMeta, now: Date.now(), signal })
1324
+ },
1125
1325
  staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
1126
- refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE handles real-time updates; this is a fallback
1326
+ refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE-driven invalidation handles real-time updates; this is the no-SSE fallback
1127
1327
  enabled,
1128
1328
  })
1129
1329
  }
@@ -1253,25 +1453,44 @@ export interface NodeMetrics {
1253
1453
  }
1254
1454
  }
1255
1455
 
1456
+ async function fetchMetricsOrNull<T>(path: string): Promise<T | null> {
1457
+ try {
1458
+ return await fetchJSON<T>(path)
1459
+ } catch (error) {
1460
+ if (isMetricsUnavailableError(error)) return null
1461
+ throw error
1462
+ }
1463
+ }
1464
+
1465
+ function retryMetricsQuery(failureCount: number, error: unknown): boolean {
1466
+ return !isMetricsUnavailableError(error) && failureCount < 1
1467
+ }
1468
+
1256
1469
  // Fetch metrics for a specific pod
1257
- export function usePodMetrics(namespace: string, podName: string) {
1258
- return useQuery<PodMetrics>({
1470
+ export function usePodMetrics(namespace: string, podName: string, options?: { enabled?: boolean }) {
1471
+ return useQuery<PodMetrics | null>({
1259
1472
  queryKey: ['pod-metrics', namespace, podName],
1260
- queryFn: () => fetchJSON(`/metrics/pods/${namespace}/${podName}`),
1261
- enabled: Boolean(namespace && podName),
1262
- staleTime: 15000, // Metrics are fresh for 15 seconds
1263
- refetchInterval: 30000, // Refresh every 30 seconds
1473
+ queryFn: () => fetchMetricsOrNull<PodMetrics>(`/metrics/pods/${namespace}/${podName}`),
1474
+ enabled: Boolean(namespace && podName) && (options?.enabled ?? true),
1475
+ staleTime: 15000,
1476
+ refetchInterval: 30000,
1477
+ refetchOnMount: 'always',
1478
+ refetchOnReconnect: 'always',
1479
+ retry: retryMetricsQuery,
1264
1480
  })
1265
1481
  }
1266
1482
 
1267
1483
  // Fetch metrics for a specific node
1268
- export function useNodeMetrics(nodeName: string) {
1269
- return useQuery<NodeMetrics>({
1484
+ export function useNodeMetrics(nodeName: string, options?: { enabled?: boolean }) {
1485
+ return useQuery<NodeMetrics | null>({
1270
1486
  queryKey: ['node-metrics', nodeName],
1271
- queryFn: () => fetchJSON(`/metrics/nodes/${nodeName}`),
1272
- enabled: Boolean(nodeName),
1487
+ queryFn: () => fetchMetricsOrNull<NodeMetrics>(`/metrics/nodes/${nodeName}`),
1488
+ enabled: Boolean(nodeName) && (options?.enabled ?? true),
1273
1489
  staleTime: 15000,
1274
1490
  refetchInterval: 30000,
1491
+ refetchOnMount: 'always',
1492
+ refetchOnReconnect: 'always',
1493
+ retry: retryMetricsQuery,
1275
1494
  })
1276
1495
  }
1277
1496
 
@@ -1295,19 +1514,57 @@ export interface PodMetricsHistory {
1295
1514
  name: string
1296
1515
  containers: ContainerMetricsHistory[]
1297
1516
  collectionError?: string
1517
+ rawCollectionError?: string
1518
+ metricsUnavailableDiagnosis?: string
1519
+ metricsUnavailable?: boolean
1520
+ metricsUnavailableReason?: string
1298
1521
  }
1299
1522
 
1300
1523
  export interface NodeMetricsHistory {
1301
1524
  name: string
1302
1525
  dataPoints: MetricsDataPoint[]
1303
1526
  collectionError?: string
1527
+ rawCollectionError?: string
1528
+ metricsUnavailableDiagnosis?: string
1529
+ metricsUnavailable?: boolean
1530
+ metricsUnavailableReason?: string
1531
+ }
1532
+
1533
+ function withoutCollectionError<T extends { collectionError?: string; rawCollectionError?: string }>(history: T): T {
1534
+ const next = { ...history }
1535
+ delete next.collectionError
1536
+ delete next.rawCollectionError
1537
+ return next
1538
+ }
1539
+
1540
+ export function normalizePodMetricsHistory(history: PodMetricsHistory): PodMetricsHistory {
1541
+ if (history.metricsUnavailable !== true) return history
1542
+ return { ...withoutCollectionError(history), metricsUnavailable: true, metricsUnavailableReason: history.rawCollectionError || history.collectionError }
1543
+ }
1544
+
1545
+ export function normalizeNodeMetricsHistory(history: NodeMetricsHistory): NodeMetricsHistory {
1546
+ if (history.metricsUnavailable !== true) return history
1547
+ return { ...withoutCollectionError(history), metricsUnavailable: true, metricsUnavailableReason: history.rawCollectionError || history.collectionError }
1548
+ }
1549
+
1550
+ export function shouldFetchLiveMetrics(historySettled: boolean, metricsUnavailable: boolean): boolean {
1551
+ return historySettled && !metricsUnavailable
1552
+ }
1553
+
1554
+ export function isLiveMetricsUnavailable(liveMetricsEnabled: boolean, metrics: unknown): boolean {
1555
+ return liveMetricsEnabled && metrics === null
1556
+ }
1557
+
1558
+ export function getVisibleLiveMetrics<T>(liveMetricsEnabled: boolean, metricsUnavailable: boolean, metrics: T | null | undefined): T | undefined {
1559
+ if (!liveMetricsEnabled || metricsUnavailable) return undefined
1560
+ return metrics ?? undefined
1304
1561
  }
1305
1562
 
1306
1563
  // Fetch historical metrics for a pod (last ~1 hour)
1307
1564
  export function usePodMetricsHistory(namespace: string, podName: string) {
1308
1565
  return useQuery<PodMetricsHistory>({
1309
1566
  queryKey: ['pod-metrics-history', namespace, podName],
1310
- queryFn: () => fetchJSON(`/metrics/pods/${namespace}/${podName}/history`),
1567
+ queryFn: async () => normalizePodMetricsHistory(await fetchJSON<PodMetricsHistory>(`/metrics/pods/${namespace}/${podName}/history`)),
1311
1568
  enabled: Boolean(namespace && podName),
1312
1569
  staleTime: 25000, // Slightly less than poll interval
1313
1570
  refetchInterval: 30000, // Match the backend poll interval
@@ -1318,7 +1575,7 @@ export function usePodMetricsHistory(namespace: string, podName: string) {
1318
1575
  export function useNodeMetricsHistory(nodeName: string) {
1319
1576
  return useQuery<NodeMetricsHistory>({
1320
1577
  queryKey: ['node-metrics-history', nodeName],
1321
- queryFn: () => fetchJSON(`/metrics/nodes/${nodeName}/history`),
1578
+ queryFn: async () => normalizeNodeMetricsHistory(await fetchJSON<NodeMetricsHistory>(`/metrics/nodes/${nodeName}/history`)),
1322
1579
  enabled: Boolean(nodeName),
1323
1580
  staleTime: 25000,
1324
1581
  refetchInterval: 30000,
@@ -1555,9 +1812,12 @@ export function useAutoPromConnect(): void {
1555
1812
  const timeout = window.setTimeout(() => {
1556
1813
  // Direct apiFetch (not via the usePrometheusConnect mutation) so the
1557
1814
  // meta-driven toast handler stays silent — the user didn't click anything.
1558
- apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
1559
- .then(resp => {
1815
+ apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, { method: 'POST' })
1816
+ .then(async resp => {
1560
1817
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
1818
+ const nextStatus = await resp.json() as PrometheusStatus
1819
+ queryClient.setQueryData(['prometheus-status'], nextStatus)
1820
+ if (!nextStatus.connected) throw new Error(nextStatus.error || 'Prometheus unavailable')
1561
1821
  queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
1562
1822
  })
1563
1823
  .catch(() => {
@@ -2054,6 +2314,17 @@ export function useApplyResource() {
2054
2314
  // CronJob operations
2055
2315
  // ============================================================================
2056
2316
 
2317
+ function invalidateCronJobOperationQueries(queryClient: ReturnType<typeof useQueryClient>, namespace: string, name: string) {
2318
+ queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2319
+ queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
2320
+ queryClient.invalidateQueries({ queryKey: ['resource', 'cronjobs', namespace, name] })
2321
+ queryClient.invalidateQueries({ queryKey: ['workload-runs', 'cronjobs', namespace, name] })
2322
+ queryClient.invalidateQueries({ queryKey: ['applications'] })
2323
+ queryClient.invalidateQueries({ queryKey: ['dashboard'] })
2324
+ queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
2325
+ queryClient.invalidateQueries({ queryKey: ['topology'] })
2326
+ }
2327
+
2057
2328
  // Trigger a CronJob (create a Job from it)
2058
2329
  export function useTriggerCronJob() {
2059
2330
  const queryClient = useQueryClient()
@@ -2073,10 +2344,8 @@ export function useTriggerCronJob() {
2073
2344
  errorMessage: 'Failed to trigger CronJob',
2074
2345
  successMessage: 'CronJob triggered',
2075
2346
  },
2076
- onSuccess: () => {
2077
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2078
- queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
2079
- queryClient.invalidateQueries({ queryKey: ['topology'] })
2347
+ onSuccess: (_, variables) => {
2348
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
2080
2349
  },
2081
2350
  })
2082
2351
  }
@@ -2100,9 +2369,8 @@ export function useSuspendCronJob() {
2100
2369
  errorMessage: 'Failed to suspend CronJob',
2101
2370
  successMessage: 'CronJob suspended',
2102
2371
  },
2103
- onSuccess: () => {
2104
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2105
- queryClient.invalidateQueries({ queryKey: ['topology'] })
2372
+ onSuccess: (_, variables) => {
2373
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
2106
2374
  },
2107
2375
  })
2108
2376
  }
@@ -2126,9 +2394,8 @@ export function useResumeCronJob() {
2126
2394
  errorMessage: 'Failed to resume CronJob',
2127
2395
  successMessage: 'CronJob resumed',
2128
2396
  },
2129
- onSuccess: () => {
2130
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2131
- queryClient.invalidateQueries({ queryKey: ['topology'] })
2397
+ onSuccess: (_, variables) => {
2398
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
2132
2399
  },
2133
2400
  })
2134
2401
  }
@@ -2361,11 +2628,11 @@ export function useHelmReleases(namespaces: string[] = []) {
2361
2628
  }
2362
2629
 
2363
2630
  // Get details for a specific Helm release
2364
- export function useHelmRelease(namespace: string, name: string) {
2631
+ export function useHelmRelease(namespace: string, name: string, options?: { enabled?: boolean }) {
2365
2632
  return useQuery<HelmReleaseDetail>({
2366
2633
  queryKey: ['helm-release', namespace, name],
2367
2634
  queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}`),
2368
- enabled: Boolean(namespace && name),
2635
+ enabled: Boolean(namespace && name) && (options?.enabled ?? true),
2369
2636
  staleTime: 5000,
2370
2637
  refetchInterval: 10000, // Poll for live resource status updates (post-upgrade/rollback)
2371
2638
  })
@@ -2652,19 +2919,24 @@ function streamHelmProgress(
2652
2919
  })
2653
2920
  }
2654
2921
 
2655
- // Upgrade a release with progress streaming via SSE
2922
+ // When `values` is provided, the upgrade applies exactly those edited values
2923
+ // instead of carrying the release's prior values over blindly.
2656
2924
  export function upgradeWithProgress(
2657
2925
  namespace: string,
2658
2926
  name: string,
2659
2927
  version: string,
2660
2928
  repositoryName: string | undefined,
2661
- onProgress: (event: InstallProgressEvent) => void
2929
+ onProgress: (event: InstallProgressEvent) => void,
2930
+ values?: Record<string, unknown>
2662
2931
  ): Promise<void> {
2663
2932
  const params = new URLSearchParams({ version })
2664
2933
  if (repositoryName) params.set('repository', repositoryName)
2934
+ const options: RequestInit = values
2935
+ ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values }) }
2936
+ : { method: 'POST' }
2665
2937
  return streamHelmProgress(
2666
2938
  `${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?${params.toString()}`,
2667
- { method: 'POST' },
2939
+ options,
2668
2940
  onProgress,
2669
2941
  'Upgrade failed',
2670
2942
  ).then(() => {})
@@ -2685,14 +2957,15 @@ export function rollbackWithProgress(
2685
2957
  ).then(() => {})
2686
2958
  }
2687
2959
 
2688
- // Preview values change (dry-run upgrade)
2960
+ // When `version` is supplied, preview renders against that target chart version
2961
+ // instead of the release's current chart.
2689
2962
  export function useHelmPreviewValues() {
2690
- return useMutation<ValuesPreviewResponse, Error, { namespace: string; name: string; values: Record<string, unknown> }>({
2691
- mutationFn: async ({ namespace, name, values }) => {
2963
+ return useMutation<ValuesPreviewResponse, Error, { namespace: string; name: string; values: Record<string, unknown>; version?: string; repository?: string }>({
2964
+ mutationFn: async ({ namespace, name, values, version, repository }) => {
2692
2965
  const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`, {
2693
2966
  method: 'POST',
2694
2967
  headers: { 'Content-Type': 'application/json' },
2695
- body: JSON.stringify({ values }),
2968
+ body: JSON.stringify({ values, version, repository }),
2696
2969
  })
2697
2970
  if (!response.ok) {
2698
2971
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
@@ -3443,6 +3716,44 @@ export interface WorkloadLogsResponse {
3443
3716
  timestamp: string
3444
3717
  content: string
3445
3718
  }[]
3719
+ emptyReason?: string
3720
+ emptyMessage?: string
3721
+ command?: string
3722
+ }
3723
+
3724
+ export interface WorkloadRun {
3725
+ kind: string
3726
+ namespace: string
3727
+ name: string
3728
+ phase: string
3729
+ active: boolean
3730
+ startedAt?: string
3731
+ finishedAt?: string
3732
+ scheduledAt?: string
3733
+ trigger?: 'manual' | 'schedule' | string
3734
+ message?: string
3735
+ succeeded?: number
3736
+ failed?: number
3737
+ running?: number
3738
+ desired?: number
3739
+ parallelism?: number
3740
+ progress?: string
3741
+ template?: string
3742
+ launcher?: {
3743
+ kind: string
3744
+ namespace?: string
3745
+ name: string
3746
+ group?: string
3747
+ }
3748
+ podTotal?: number
3749
+ podSucceeded?: number
3750
+ podFailed?: number
3751
+ podRunning?: number
3752
+ podPending?: number
3753
+ }
3754
+
3755
+ export interface WorkloadRunsResponse {
3756
+ runs: WorkloadRun[]
3446
3757
  }
3447
3758
 
3448
3759
  // Fetch pods for a workload
@@ -3455,6 +3766,24 @@ export function useWorkloadPods(kind: string, namespace: string, name: string) {
3455
3766
  })
3456
3767
  }
3457
3768
 
3769
+ export function useWorkloadRuns(kind: string, namespace: string, name: string, enabled = true, options?: { refetchActive?: boolean; clusterScoped?: boolean }) {
3770
+ const clusterScoped = options?.clusterScoped ?? false
3771
+ const ns = clusterScoped ? '_' : namespace
3772
+ const params = new URLSearchParams()
3773
+ if (clusterScoped) params.set('clusterScoped', 'true')
3774
+ const queryString = params.toString()
3775
+
3776
+ return useQuery<WorkloadRunsResponse>({
3777
+ queryKey: ['workload-runs', kind, namespace, name, clusterScoped],
3778
+ queryFn: () => fetchJSON(`/workloads/${kind}/${ns}/${name}/runs${queryString ? `?${queryString}` : ''}`),
3779
+ enabled: enabled && Boolean(kind && name && (namespace || clusterScoped)),
3780
+ staleTime: 10000,
3781
+ refetchInterval: options?.refetchActive
3782
+ ? (query) => query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000
3783
+ : false,
3784
+ })
3785
+ }
3786
+
3458
3787
  // Fetch logs for a workload (non-streaming)
3459
3788
  export function useWorkloadLogs(
3460
3789
  kind: string,