@skyhook-io/radar-app 1.8.6 → 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 (75) hide show
  1. package/README.md +7 -1
  2. package/package.json +11 -9
  3. package/src/App.tsx +221 -181
  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.ts +262 -34
  8. package/src/api/diagnose.ts +288 -0
  9. package/src/api/timelineSource.test.ts +217 -0
  10. package/src/api/timelineSource.ts +580 -0
  11. package/src/components/ConnectionErrorView.tsx +174 -70
  12. package/src/components/ContextSwitcher.tsx +13 -5
  13. package/src/components/applications/ApplicationsView.tsx +327 -26
  14. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  15. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  16. package/src/components/diagnose/AISettings.tsx +121 -0
  17. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  18. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  19. package/src/components/diagnose/Home.tsx +163 -0
  20. package/src/components/diagnose/InvestigationView.tsx +604 -0
  21. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  22. package/src/components/diagnose/launch.ts +65 -0
  23. package/src/components/diagnose/parts.tsx +1689 -0
  24. package/src/components/dock/BottomDock.tsx +2 -3
  25. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  26. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  27. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  28. package/src/components/execution/batch-run-actions.test.ts +48 -0
  29. package/src/components/execution/batch-run-actions.ts +24 -0
  30. package/src/components/execution/batch-timeline.test.ts +57 -0
  31. package/src/components/execution/batch-timeline.ts +46 -0
  32. package/src/components/execution/execution-definition.test.ts +208 -0
  33. package/src/components/execution/execution-definition.ts +245 -0
  34. package/src/components/helm/ChartBrowser.tsx +2 -3
  35. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  36. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  37. package/src/components/helm/HelmView.tsx +2 -3
  38. package/src/components/helm/InstallWizard.tsx +3 -5
  39. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  40. package/src/components/helm/ValuesDiffPreview.tsx +15 -4
  41. package/src/components/home/HomeView.tsx +17 -15
  42. package/src/components/home/MCPSetupDialog.tsx +1 -1
  43. package/src/components/home/mcpToolCatalog.ts +2 -2
  44. package/src/components/issues/IssuesPane.tsx +9 -1
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  46. package/src/components/portforward/PortForwardButton.tsx +2 -2
  47. package/src/components/portforward/PortForwardManager.tsx +19 -14
  48. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  49. package/src/components/resource/RightsizingStrip.tsx +0 -3
  50. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  51. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  52. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  53. package/src/components/resources/ResourcesView.tsx +13 -4
  54. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  55. package/src/components/resources/renderers/index.ts +1 -0
  56. package/src/components/settings/SettingsDialog.tsx +80 -23
  57. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  58. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  59. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  60. package/src/components/timeline/TimelineList.tsx +51 -10
  61. package/src/components/timeline/TimelineView.tsx +701 -15
  62. package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
  63. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  64. package/src/components/traffic/TrafficView.tsx +8 -4
  65. package/src/components/ui/CommandPalette.tsx +2 -2
  66. package/src/components/workload/WorkloadView.tsx +319 -35
  67. package/src/context/ConnectionContext.tsx +109 -11
  68. package/src/context/DiagnoseCustomization.tsx +42 -0
  69. package/src/context/TimelineSource.tsx +50 -0
  70. package/src/hooks/useClusterLoadState.ts +73 -0
  71. package/src/hooks/useEventSource.ts +6 -0
  72. package/src/index.css +157 -0
  73. package/src/index.ts +12 -0
  74. package/src/types/clusterLoadState.ts +33 -0
  75. 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'
@@ -371,11 +371,12 @@ export interface DashboardCRDsResponse {
371
371
  topCRDs: DashboardCRDCount[]
372
372
  }
373
373
 
374
- export function useDashboard(namespaces: string[] = []) {
374
+ export function useDashboard(namespaces: string[] = [], options?: { enabled?: boolean }) {
375
375
  const params = namespaces.length > 0 ? `?namespaces=${namespaces.join(',')}` : ''
376
376
  return useQuery<DashboardResponse>({
377
377
  queryKey: ['dashboard', namespaces],
378
378
  queryFn: () => fetchJSON(`/dashboard${params}`),
379
+ enabled: options?.enabled ?? true,
379
380
  staleTime: 15000, // 15 seconds
380
381
  refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
381
382
  })
@@ -982,34 +983,55 @@ export function useNamespaces() {
982
983
  }
983
984
 
984
985
  // Topology (for manual refresh)
985
- 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 }) {
986
987
  const params = new URLSearchParams()
987
988
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
988
989
  if (viewMode) params.set('view', viewMode)
990
+ if (options?.includeReplicaSets) params.set('includeReplicaSets', 'true')
989
991
  const queryString = params.toString()
990
992
 
991
993
  return useQuery<Topology>({
992
- queryKey: ['topology', namespaces, viewMode],
994
+ queryKey: ['topology', namespaces, viewMode, options?.includeReplicaSets ?? false],
993
995
  queryFn: () => fetchJSON(`/topology${queryString ? `?${queryString}` : ''}`),
994
996
  staleTime: 5000, // 5 seconds
995
997
  enabled: options?.enabled !== false,
998
+ refetchInterval: options?.refetchInterval,
996
999
  })
997
1000
  }
998
1001
 
999
- export function useApplications(namespaces: string[]) {
1002
+ export function useApplications(namespaces: string[], options?: { enabled?: boolean }) {
1000
1003
  const params = new URLSearchParams()
1001
1004
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
1002
1005
  const queryString = params.toString()
1003
1006
 
1007
+ const enabled = options?.enabled !== false
1004
1008
  return useQuery<{ applications: AppRow[] }>({
1005
1009
  queryKey: ['applications', namespaces],
1006
1010
  queryFn: () => fetchJSON(`/applications${queryString ? `?${queryString}` : ''}`),
1007
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,
1008
1030
  refetchInterval: APPLICATIONS_REFRESH_INTERVAL_MS,
1009
1031
  })
1010
1032
  }
1011
1033
 
1012
- 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 }) {
1013
1035
  const ns = namespace || '_'
1014
1036
  const params = new URLSearchParams()
1015
1037
  if (group) params.set('group', group)
@@ -1019,7 +1041,7 @@ export function useGitOpsTree(kind: string, namespace: string, name: string, gro
1019
1041
  return useQuery<GitOpsResourceTree>({
1020
1042
  queryKey: ['gitops-tree', kind, namespace, name, group, namespaces],
1021
1043
  queryFn: () => fetchJSON(`/gitops/tree/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1022
- enabled: Boolean(kind && name),
1044
+ enabled: Boolean(kind && name) && (options?.enabled ?? true),
1023
1045
  staleTime: 5000,
1024
1046
  })
1025
1047
  }
@@ -1051,7 +1073,7 @@ export function useGitOpsInsights(kind: string, namespace: string, name: string,
1051
1073
 
1052
1074
  // Generic resource fetching - returns resource with relationships
1053
1075
  // Uses '_' as placeholder for cluster-scoped resources (empty namespace)
1054
- 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 }) {
1055
1077
  // For cluster-scoped resources, use '_' as namespace placeholder
1056
1078
  const ns = namespace || '_'
1057
1079
  const params = new URLSearchParams()
@@ -1061,7 +1083,8 @@ export function useResource<T>(kind: string, namespace: string, name: string, gr
1061
1083
  const query = useQuery<ResourceWithRelationships<T>>({
1062
1084
  queryKey: ['resource', kind, namespace, name, group],
1063
1085
  queryFn: () => fetchJSON(`/resources/${kind}/${ns}/${name}${queryString ? `?${queryString}` : ''}`),
1064
- 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,
1065
1088
  })
1066
1089
 
1067
1090
  // Extract resource and relationships from the response
@@ -1112,7 +1135,10 @@ export function useResources<T>(
1112
1135
  // Timeline changes (unified view of changes + K8s events)
1113
1136
  export interface UseChangesOptions {
1114
1137
  namespaces?: string[]
1115
- 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[]
1116
1142
  timeRange?: TimeRange
1117
1143
  filter?: string // Filter preset name ('default', 'all', 'warnings-only', 'workloads')
1118
1144
  includeK8sEvents?: boolean
@@ -1120,6 +1146,122 @@ export interface UseChangesOptions {
1120
1146
  includeDeleted?: boolean
1121
1147
  limit?: number
1122
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
1123
1265
  }
1124
1266
 
1125
1267
  function getTimeRangeDate(range: TimeRange): Date | null {
@@ -1136,17 +1278,26 @@ function getTimeRangeDate(range: TimeRange): Date | null {
1136
1278
  return new Date(now.getTime() - 6 * 60 * 60 * 1000)
1137
1279
  case '24h':
1138
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)
1139
1285
  default:
1140
1286
  return null
1141
1287
  }
1142
1288
  }
1143
1289
 
1144
1290
  export function useChanges(options: UseChangesOptions = {}) {
1145
- 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
1146
1297
 
1147
1298
  const params = new URLSearchParams()
1148
1299
  if (namespaces.length > 0) params.set('namespaces', namespaces.join(','))
1149
- if (kind) params.set('kind', kind)
1300
+ if (serverKind) params.set('kind', serverKind)
1150
1301
  if (filter) params.set('filter', filter)
1151
1302
  if (!includeK8sEvents) params.set('include_k8s_events', 'false')
1152
1303
  if (includeManaged) params.set('include_managed', 'true')
@@ -1159,12 +1310,20 @@ export function useChanges(options: UseChangesOptions = {}) {
1159
1310
  }
1160
1311
 
1161
1312
  const queryString = params.toString()
1313
+ const path = `/changes${queryString ? `?${queryString}` : ''}`
1314
+ const queryKey = ['changes', namespaces, serverKind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit]
1162
1315
 
1163
1316
  return useQuery<TimelineEvent[]>({
1164
- queryKey: ['changes', namespaces, kind, timeRange, filter, includeK8sEvents, includeManaged, includeDeleted, limit],
1165
- 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
+ },
1166
1325
  staleTime: 5000, // Consider data stale after 5 seconds to ensure fresh data on navigation
1167
- refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE handles real-time updates; this is a fallback
1326
+ refetchInterval: CHANGES_REFRESH_INTERVAL_MS, // SSE-driven invalidation handles real-time updates; this is the no-SSE fallback
1168
1327
  enabled,
1169
1328
  })
1170
1329
  }
@@ -2155,6 +2314,17 @@ export function useApplyResource() {
2155
2314
  // CronJob operations
2156
2315
  // ============================================================================
2157
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
+
2158
2328
  // Trigger a CronJob (create a Job from it)
2159
2329
  export function useTriggerCronJob() {
2160
2330
  const queryClient = useQueryClient()
@@ -2174,10 +2344,8 @@ export function useTriggerCronJob() {
2174
2344
  errorMessage: 'Failed to trigger CronJob',
2175
2345
  successMessage: 'CronJob triggered',
2176
2346
  },
2177
- onSuccess: () => {
2178
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2179
- queryClient.invalidateQueries({ queryKey: ['resources', 'jobs'] })
2180
- queryClient.invalidateQueries({ queryKey: ['topology'] })
2347
+ onSuccess: (_, variables) => {
2348
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
2181
2349
  },
2182
2350
  })
2183
2351
  }
@@ -2201,9 +2369,8 @@ export function useSuspendCronJob() {
2201
2369
  errorMessage: 'Failed to suspend CronJob',
2202
2370
  successMessage: 'CronJob suspended',
2203
2371
  },
2204
- onSuccess: () => {
2205
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2206
- queryClient.invalidateQueries({ queryKey: ['topology'] })
2372
+ onSuccess: (_, variables) => {
2373
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
2207
2374
  },
2208
2375
  })
2209
2376
  }
@@ -2227,9 +2394,8 @@ export function useResumeCronJob() {
2227
2394
  errorMessage: 'Failed to resume CronJob',
2228
2395
  successMessage: 'CronJob resumed',
2229
2396
  },
2230
- onSuccess: () => {
2231
- queryClient.invalidateQueries({ queryKey: ['resources', 'cronjobs'] })
2232
- queryClient.invalidateQueries({ queryKey: ['topology'] })
2397
+ onSuccess: (_, variables) => {
2398
+ invalidateCronJobOperationQueries(queryClient, variables.namespace, variables.name)
2233
2399
  },
2234
2400
  })
2235
2401
  }
@@ -2462,11 +2628,11 @@ export function useHelmReleases(namespaces: string[] = []) {
2462
2628
  }
2463
2629
 
2464
2630
  // Get details for a specific Helm release
2465
- export function useHelmRelease(namespace: string, name: string) {
2631
+ export function useHelmRelease(namespace: string, name: string, options?: { enabled?: boolean }) {
2466
2632
  return useQuery<HelmReleaseDetail>({
2467
2633
  queryKey: ['helm-release', namespace, name],
2468
2634
  queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}`),
2469
- enabled: Boolean(namespace && name),
2635
+ enabled: Boolean(namespace && name) && (options?.enabled ?? true),
2470
2636
  staleTime: 5000,
2471
2637
  refetchInterval: 10000, // Poll for live resource status updates (post-upgrade/rollback)
2472
2638
  })
@@ -2753,19 +2919,24 @@ function streamHelmProgress(
2753
2919
  })
2754
2920
  }
2755
2921
 
2756
- // 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.
2757
2924
  export function upgradeWithProgress(
2758
2925
  namespace: string,
2759
2926
  name: string,
2760
2927
  version: string,
2761
2928
  repositoryName: string | undefined,
2762
- onProgress: (event: InstallProgressEvent) => void
2929
+ onProgress: (event: InstallProgressEvent) => void,
2930
+ values?: Record<string, unknown>
2763
2931
  ): Promise<void> {
2764
2932
  const params = new URLSearchParams({ version })
2765
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' }
2766
2937
  return streamHelmProgress(
2767
2938
  `${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?${params.toString()}`,
2768
- { method: 'POST' },
2939
+ options,
2769
2940
  onProgress,
2770
2941
  'Upgrade failed',
2771
2942
  ).then(() => {})
@@ -2786,14 +2957,15 @@ export function rollbackWithProgress(
2786
2957
  ).then(() => {})
2787
2958
  }
2788
2959
 
2789
- // 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.
2790
2962
  export function useHelmPreviewValues() {
2791
- return useMutation<ValuesPreviewResponse, Error, { namespace: string; name: string; values: Record<string, unknown> }>({
2792
- 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 }) => {
2793
2965
  const response = await apiFetch(`${getApiBase()}/helm/releases/${namespace}/${name}/values/preview`, {
2794
2966
  method: 'POST',
2795
2967
  headers: { 'Content-Type': 'application/json' },
2796
- body: JSON.stringify({ values }),
2968
+ body: JSON.stringify({ values, version, repository }),
2797
2969
  })
2798
2970
  if (!response.ok) {
2799
2971
  const error = await response.json().catch(() => ({ error: 'Unknown error' }))
@@ -3544,6 +3716,44 @@ export interface WorkloadLogsResponse {
3544
3716
  timestamp: string
3545
3717
  content: string
3546
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[]
3547
3757
  }
3548
3758
 
3549
3759
  // Fetch pods for a workload
@@ -3556,6 +3766,24 @@ export function useWorkloadPods(kind: string, namespace: string, name: string) {
3556
3766
  })
3557
3767
  }
3558
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
+
3559
3787
  // Fetch logs for a workload (non-streaming)
3560
3788
  export function useWorkloadLogs(
3561
3789
  kind: string,