@skyhook-io/radar-app 1.14.5 → 1.14.6

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 (83) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +91 -328
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.jobset.test.ts +18 -0
  6. package/src/api/client.resource-identity.test.ts +44 -0
  7. package/src/api/client.rightsizing.test.ts +7 -3
  8. package/src/api/client.ts +233 -78
  9. package/src/api/preferences.test.ts +54 -0
  10. package/src/api/preferences.ts +38 -0
  11. package/src/components/CloudFunnelButton.test.tsx +155 -0
  12. package/src/components/CloudFunnelButton.tsx +14 -8
  13. package/src/components/ContextSwitcher.tsx +13 -132
  14. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  15. package/src/components/applications/ApplicationsView.tsx +5 -3
  16. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  17. package/src/components/diagnose/AgentControls.tsx +26 -3
  18. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  19. package/src/components/diagnose/agentCatalog.ts +6 -0
  20. package/src/components/diagnose/launch.test.ts +23 -0
  21. package/src/components/diagnose/launch.ts +4 -0
  22. package/src/components/diagnose/parts.test.tsx +18 -0
  23. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  24. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  25. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  26. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  27. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  28. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  29. package/src/components/execution/BatchExecutionView.tsx +324 -65
  30. package/src/components/execution/JobSetAdmission.test.tsx +42 -0
  31. package/src/components/execution/JobSetAdmission.tsx +17 -0
  32. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  33. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  34. package/src/components/execution/batch-run-actions.test.ts +1 -0
  35. package/src/components/execution/batch-timeline.test.ts +3 -0
  36. package/src/components/execution/execution-definition.test.ts +15 -1
  37. package/src/components/execution/execution-definition.ts +4 -0
  38. package/src/components/execution/member-collection.render.test.tsx +142 -0
  39. package/src/components/gitops/GitOpsView.tsx +33 -2
  40. package/src/components/gitops/destination-toast.test.ts +33 -0
  41. package/src/components/gitops/destination-toast.ts +46 -0
  42. package/src/components/gitops/useDestinationCluster.ts +30 -0
  43. package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
  44. package/src/components/home/MCPSetupDialog.tsx +10 -0
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  46. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  47. package/src/components/nav/PrimaryNavRail.tsx +2 -5
  48. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  49. package/src/components/resource/PVCUsageBar.tsx +62 -16
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  51. package/src/components/resources/ResourcesView.tsx +3 -3
  52. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  53. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  54. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  55. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  56. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  57. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  58. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  59. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  60. package/src/components/rightsizing/copy.test.ts +7 -0
  61. package/src/components/rightsizing/model.test.ts +2 -1
  62. package/src/components/rightsizing/notices.test.tsx +4 -3
  63. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  64. package/src/components/settings/SettingsDialog.tsx +157 -42
  65. package/src/components/settings/settings-state.test.ts +18 -0
  66. package/src/components/settings/settings-state.ts +18 -0
  67. package/src/components/timeline/TimelineView.tsx +9 -3
  68. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  69. package/src/components/ui/command-items.ts +0 -3
  70. package/src/components/useContextSwitchFlow.tsx +147 -0
  71. package/src/components/workload/WorkloadView.test.ts +17 -1
  72. package/src/components/workload/WorkloadView.tsx +36 -18
  73. package/src/context/ContextSwitchContext.tsx +18 -2
  74. package/src/context/NavCustomization.tsx +4 -58
  75. package/src/context/ThemeContext.tsx +3 -11
  76. package/src/hooks/useClusterLoadState.ts +2 -2
  77. package/src/hooks/useFavorites.ts +3 -5
  78. package/src/utils/auditBadges.ts +1 -1
  79. package/src/utils/navigation.test.ts +18 -1
  80. package/src/utils/navigation.ts +12 -5
  81. package/src/utils/topology-selection.test.ts +56 -0
  82. package/src/utils/topology-selection.ts +9 -9
  83. package/src/components/ui/CommandPalette.tsx +0 -261
package/src/RadarApp.tsx CHANGED
@@ -84,12 +84,7 @@ export interface RadarAppProps {
84
84
  * prefer to share its client rather than nest two providers.
85
85
  */
86
86
  queryClient?: QueryClient;
87
- /**
88
- * Slot-based customization of Radar's top nav. Use to inject host-app
89
- * brand, replace the kubeconfig context picker with a product-level
90
- * cluster switcher, and append items to the right action bar.
91
- * See ./context/NavCustomization for the slot shape.
92
- */
87
+ /** Embedded layout and host navigation hooks for Radar Hub. */
93
88
  navSlots?: NavCustomization;
94
89
  /**
95
90
  * Whether Radar may set the browser tab title (`document.title`) per view.
@@ -128,14 +123,14 @@ export interface RadarAppProps {
128
123
  /**
129
124
  * Initial route for `router: 'memory'` (ignored for 'browser'). Lets a host
130
125
  * deep-link a specific view (e.g. '/topology') without owning the URL bar —
131
- * used with `navSlots.chrome: 'none'` to render a single per-cluster view
126
+ * used with `navSlots.embedded: true` to render a single per-cluster view
132
127
  * chromeless under the host's own chrome (Radar Hub's per-cluster destinations).
133
128
  */
134
129
  initialPath?: string;
135
130
  /**
136
131
  * Reports cluster-data warmup after the main connection is usable. Embedders
137
132
  * with their own chrome (Radar Hub) can render this in their topbar while
138
- * Radar runs with `navSlots.chrome: 'none'`.
133
+ * Radar runs with `navSlots.embedded: true`.
139
134
  */
140
135
  onClusterLoadStateChange?: (state: ClusterLoadState) => void;
141
136
  /**
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { useWorkloadRuns, type WorkloadRunsResponse } from './client'
4
+
5
+ vi.mock('@tanstack/react-query', async (original) => ({ ...await original<typeof import('@tanstack/react-query')>(), useQuery: vi.fn(() => ({})) }))
6
+
7
+ describe('JobSet member polling', () => {
8
+ it('refreshes an active selected member even when the displayed window is idle', () => {
9
+ useWorkloadRuns('jobsets', 'training', 'distributed', true, { refetchActive: true, role: 'prepare', selected: 'jobs/training/worker' })
10
+ const options = vi.mocked(useQuery).mock.calls.at(-1)![0]
11
+ const interval = options.refetchInterval as (query: { state: { data: WorkloadRunsResponse } }) => number
12
+ const run = { group: 'batch', kind: 'jobs', namespace: 'training', name: 'prepare', phase: 'Succeeded', active: false }
13
+ const response: WorkloadRunsResponse = { collection: 'members', runs: [run], selected: { ...run, name: 'worker', phase: 'Running', active: true }, total: 2, filteredTotal: 1, truncated: false }
14
+ expect(interval({ state: { data: response } })).toBe(5000)
15
+ expect(interval({ state: { data: { ...response, selected: run } } })).toBe(30000)
16
+ expect(interval({ state: { data: { ...response, runs: [{ ...run, active: true }], selected: undefined } } })).toBe(5000)
17
+ })
18
+ })
@@ -0,0 +1,44 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { useResourceAudit, useResourceEvents } from './client'
4
+
5
+ vi.mock('@tanstack/react-query', async (original) => ({
6
+ ...await original<typeof import('@tanstack/react-query')>(),
7
+ useQuery: vi.fn(() => ({})),
8
+ }))
9
+
10
+ afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals() })
11
+
12
+ describe('resource history identity', () => {
13
+ it('separates same-name core and Volcano history in requests and cache keys', async () => {
14
+ const urls: URL[] = []
15
+ vi.stubGlobal('fetch', vi.fn(async (input: string) => {
16
+ urls.push(new URL(input, 'http://localhost'))
17
+ return new Response('[]', {status: 200})
18
+ }))
19
+ useResourceEvents('jobs', 'ml', 'shared', 'batch')
20
+ useResourceEvents('jobs', 'ml', 'shared', 'batch.volcano.sh')
21
+ const queries = vi.mocked(useQuery).mock.calls.map(call => call[0])
22
+ expect(queries[0].queryKey).not.toEqual(queries[2].queryKey)
23
+ expect(queries[1].queryKey).not.toEqual(queries[3].queryKey)
24
+ for (const query of queries) await (query.queryFn as () => Promise<unknown>)()
25
+ expect(urls.map(url => url.searchParams.get('group'))).toEqual(['batch', 'batch', 'batch.volcano.sh', 'batch.volcano.sh'])
26
+ expect(urls.every(url => url.searchParams.get('kind') === 'Job')).toBe(true)
27
+ })
28
+
29
+ it('does not query an unresolved custom identity', () => {
30
+ useResourceEvents('Widget', 'ml', 'shared')
31
+ expect(vi.mocked(useQuery).mock.calls.every(call => call[0].enabled === false)).toBe(true)
32
+ })
33
+
34
+ it('preserves a core group and an exact audit group', async () => {
35
+ const fetchMock = vi.fn<typeof fetch>(async () => new Response('[]', {status: 200}))
36
+ vi.stubGlobal('fetch', fetchMock)
37
+ useResourceEvents('pods', 'ml', 'shared', '')
38
+ await (vi.mocked(useQuery).mock.calls[0][0].queryFn as () => Promise<unknown>)()
39
+ expect(new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost').searchParams.get('group')).toBe('')
40
+ useResourceAudit('IngressRoute', 'ml', 'shared', 'traefik.io')
41
+ await (vi.mocked(useQuery).mock.calls.at(-1)![0].queryFn as () => Promise<unknown>)()
42
+ expect(fetchMock).toHaveBeenLastCalledWith('/api/audit/resource/IngressRoute/ml/shared?group=traefik.io', expect.anything())
43
+ })
44
+ })
@@ -1,21 +1,25 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { skipToken } from '@tanstack/react-query'
3
2
  import { getRightsizingScanCacheConfig } from './client'
4
3
 
5
4
  describe('rightsizing scan cache config', () => {
6
- it('is manual-only and retained briefly', () => {
5
+ it('retains result lookups briefly', () => {
7
6
  const config = getRightsizingScanCacheConfig(['staging', 'default'], 'cluster-a')
8
7
 
9
- expect(config.queryFn).toBe(skipToken)
10
8
  expect(config.gcTime).toBe(5 * 60 * 1000)
11
9
  })
12
10
 
11
+ it('isolates identities', () => {
12
+ expect(getRightsizingScanCacheConfig([], 'a', 'alice').queryKey).not.toEqual(getRightsizingScanCacheConfig([], 'a', 'bob').queryKey)
13
+ })
14
+
13
15
  it('normalizes namespace order and isolates cluster and namespace scopes', () => {
14
16
  const config = getRightsizingScanCacheConfig(['staging', 'default'], 'cluster-a')
15
17
 
16
18
  expect(config.namespaceKey).toBe('default,staging')
17
19
  expect(config.queryKey).toEqual([
18
20
  'prometheus-rightsizing-scan',
21
+ '/api',
22
+ '',
19
23
  'cluster-a',
20
24
  'default,staging',
21
25
  ])
package/src/api/client.ts CHANGED
@@ -1,4 +1,7 @@
1
+ import { canonicalResourceGroup } from '@skyhook-io/k8s-ui/utils/api-resources'
2
+ import { knownKindForPluralWithGroup, pluralToKind } from '@skyhook-io/k8s-ui/utils/navigation'
1
3
  import { useEffect, useRef } from 'react'
4
+ import type { KueueAdmissionResponse } from '@skyhook-io/k8s-ui/types/scheduling'
2
5
  import type {
3
6
  AppHistory,
4
7
  AppRow,
@@ -56,7 +59,7 @@ import type {
56
59
  } from '../types'
57
60
  import type { GitOpsOperationResponse } from '../types/gitops'
58
61
  import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath, stripBasename } from './config'
59
- import { apiVersionToGroup, pluralToKind } from '../utils/navigation'
62
+ import { apiVersionToGroup } from '../utils/navigation'
60
63
  import type { DeploymentMode } from '../types'
61
64
 
62
65
  // Auto-refresh cadences (ms) — named constants for each polled hook's
@@ -723,10 +726,14 @@ export function useResourceAudit(
723
726
  kind: string,
724
727
  namespace: string,
725
728
  name: string,
729
+ group?: string,
726
730
  ) {
731
+ const params = new URLSearchParams();
732
+ if (group) params.set("group", group);
733
+ const query = params.toString();
727
734
  return useQuery<AuditFinding[]>({
728
- queryKey: ["audit", "resource", kind, namespace, name],
729
- queryFn: () => fetchJSON(`/audit/resource/${kind}/${namespace}/${name}`),
735
+ queryKey: ["audit", "resource", kind, group ?? "", namespace, name],
736
+ queryFn: () => fetchJSON(`/audit/resource/${kind}/${namespace}/${name}${query ? `?${query}` : ""}`),
730
737
  staleTime: 30000,
731
738
  });
732
739
  }
@@ -2808,30 +2815,6 @@ export function useChanges(options: UseChangesOptions = {}) {
2808
2815
  });
2809
2816
  }
2810
2817
 
2811
- // Children changes for a parent workload (e.g., ReplicaSets and Pods under a Deployment)
2812
- export function useResourceChildren(
2813
- kind: string,
2814
- namespace: string,
2815
- name: string,
2816
- timeRange: TimeRange = "1h",
2817
- ) {
2818
- const sinceDate = getTimeRangeDate(timeRange);
2819
- const params = new URLSearchParams();
2820
- if (sinceDate) {
2821
- params.set("since", sinceDate.toISOString());
2822
- }
2823
-
2824
- return useQuery<TimelineEvent[]>({
2825
- queryKey: ["resource-children", kind, namespace, name, timeRange],
2826
- queryFn: () =>
2827
- fetchJSON(
2828
- `/changes/${kind}/${namespace}/${name}/children?${params.toString()}`,
2829
- ),
2830
- enabled: Boolean(kind && namespace && name),
2831
- refetchInterval: 15000, // Refresh every 15 seconds
2832
- });
2833
- }
2834
-
2835
2818
  export interface ResourceEventsResult {
2836
2819
  k8sEvents: TimelineEvent[];
2837
2820
  updates: TimelineEvent[];
@@ -2850,10 +2833,12 @@ export function useResourceEvents(
2850
2833
  kind: string,
2851
2834
  namespace: string,
2852
2835
  name: string,
2836
+ group?: string,
2853
2837
  ): ResourceEventsResult {
2854
2838
  // The timeline store keys events by their K8s Kind (singular PascalCase, e.g. "Pod"),
2855
2839
  // but callers pass the URL-form kind ("pods").
2856
- const singularKind = pluralToKind(kind);
2840
+ const singularKind = knownKindForPluralWithGroup(kind, group ?? "") ?? pluralToKind(kind);
2841
+ const resolvedGroup = canonicalResourceGroup(singularKind, group);
2857
2842
  const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
2858
2843
 
2859
2844
  // Include managed resources — when viewing a specific resource (e.g. a Pod owned
@@ -2864,18 +2849,19 @@ export function useResourceEvents(
2864
2849
  p.set("namespace", namespace);
2865
2850
  p.set("kind", singularKind);
2866
2851
  p.set("name", name);
2852
+ if (resolvedGroup !== undefined) p.set("group", resolvedGroup);
2867
2853
  p.set("include_managed", "true");
2868
2854
  p.set("since", since);
2869
2855
  return p;
2870
2856
  };
2871
2857
 
2872
- const enabled = Boolean(kind && namespace && name);
2858
+ const enabled = Boolean(kind && namespace && name && resolvedGroup !== undefined);
2873
2859
 
2874
2860
  // K8s events: high limit so the full set is always returned. The number of
2875
2861
  // distinct K8s events per resource is naturally bounded — kubelet/controllers
2876
2862
  // dedupe via Reason+InvolvedObject and bump count.
2877
2863
  const k8sQuery = useQuery<TimelineEvent[]>({
2878
- queryKey: ["resource-events", "k8s", singularKind, namespace, name],
2864
+ queryKey: ["resource-events", "k8s", singularKind, resolvedGroup, namespace, name],
2879
2865
  queryFn: async () => {
2880
2866
  const params = baseParams();
2881
2867
  params.set("sources", "k8s_event");
@@ -2889,7 +2875,7 @@ export function useResourceEvents(
2889
2875
  // Resource updates (informer diffs + historical): bounded so a flapping
2890
2876
  // resource doesn't return an unbounded payload.
2891
2877
  const updatesQuery = useQuery<TimelineEvent[]>({
2892
- queryKey: ["resource-events", "updates", singularKind, namespace, name],
2878
+ queryKey: ["resource-events", "updates", singularKind, resolvedGroup, namespace, name],
2893
2879
  queryFn: async () => {
2894
2880
  const params = baseParams();
2895
2881
  params.set("sources", "informer,historical");
@@ -3272,9 +3258,9 @@ export type PrometheusTimeRange =
3272
3258
  "10m" | "30m" | "1h" | "3h" | "6h" | "12h" | "24h" | "48h" | "7d" | "14d";
3273
3259
 
3274
3260
  // PVC usage at a moment in time, derived from kubelet_volume_stats_*.
3275
- // HasData=false silently indicates the CSI driver doesn't report or Prom
3276
- // isn't scraping kubelet endpoints — UI should hide the gauge in that case.
3277
3261
  export interface PrometheusPVCUsage {
3262
+ // Hub packages the frontend independently from per-cluster agent upgrades.
3263
+ status?: "available" | "no_series" | "invalid_data" | "query_failed";
3278
3264
  namespace: string;
3279
3265
  name: string;
3280
3266
  used: number;
@@ -3364,6 +3350,7 @@ export interface RightsizingScanCoverage {
3364
3350
  workloadsWithData: number;
3365
3351
  batches: number;
3366
3352
  completedBatches: number;
3353
+ attemptedBatches: number;
3367
3354
  restrictedKinds?: string[];
3368
3355
  unavailableKinds?: string[];
3369
3356
  partiallyCachedKinds?: string[];
@@ -3371,6 +3358,11 @@ export interface RightsizingScanCoverage {
3371
3358
  }
3372
3359
 
3373
3360
  export interface RightsizingScanResponse {
3361
+ scanId: string;
3362
+ scanStatus: 'running' | 'finished' | 'cancelled' | 'timed_out';
3363
+ deadlineAt: string;
3364
+ expiresAt?: string;
3365
+ pollAfterSeconds?: number;
3374
3366
  state: RightsizingScanState;
3375
3367
  scannedAt: string;
3376
3368
  window: string;
@@ -3629,7 +3621,6 @@ export function usePrometheusClusterMetrics(
3629
3621
  });
3630
3622
  }
3631
3623
 
3632
- // Fetch PVC usage. hasData=false when no series — UI should hide the gauge.
3633
3624
  export function usePrometheusPVCUsage(
3634
3625
  namespace: string,
3635
3626
  name: string,
@@ -3640,7 +3631,10 @@ export function usePrometheusPVCUsage(
3640
3631
  queryFn: () => fetchJSON(`/prometheus/pvc/${namespace}/${name}`),
3641
3632
  enabled: enabled && Boolean(namespace && name),
3642
3633
  staleTime: 60000,
3643
- refetchInterval: 120000,
3634
+ refetchInterval: (query) =>
3635
+ isForbiddenError(query.state.error) ? false : 120000,
3636
+ retry: (failureCount, error) =>
3637
+ !isForbiddenError(error) && failureCount < 1,
3644
3638
  });
3645
3639
  }
3646
3640
 
@@ -3663,55 +3657,83 @@ export function usePrometheusRightsizing(
3663
3657
 
3664
3658
  const RIGHTSIZING_SCAN_CACHE_TIME = 5 * 60 * 1000;
3665
3659
 
3666
- export function getRightsizingScanCacheConfig(
3667
- namespaces: string[],
3668
- context = "",
3669
- ): {
3670
- namespaceKey: string;
3671
- queryKey: readonly ["prometheus-rightsizing-scan", string, string];
3672
- queryFn: typeof skipToken;
3673
- gcTime: number;
3674
- } {
3675
- const namespaceKey = [...namespaces].sort().join(",");
3660
+ export function getRightsizingScanCacheConfig(namespaces: string[], context = "", identity = "") {
3661
+ const namespaceKey = [...new Set(namespaces)].sort().join(",");
3676
3662
  return {
3677
3663
  namespaceKey,
3678
- queryKey: ["prometheus-rightsizing-scan", context, namespaceKey] as const,
3679
- queryFn: skipToken,
3664
+ queryKey: ["prometheus-rightsizing-scan", getApiBase(), identity, context, namespaceKey] as const,
3680
3665
  gcTime: RIGHTSIZING_SCAN_CACHE_TIME,
3681
3666
  };
3682
3667
  }
3683
3668
 
3684
- // A fleet rightsizing scan is intentionally manual. It can query seven days of
3685
- // Prometheus history for many containers, so navigation alone must never run it.
3669
+ // Navigation retrieves a retained scan; only an explicit POST starts work.
3686
3670
  export function useRightsizingScan(namespaces: string[], context = "") {
3687
3671
  const queryClient = useQueryClient();
3688
- const { namespaceKey, ...snapshotOptions } = getRightsizingScanCacheConfig(
3689
- namespaces,
3690
- context,
3691
- );
3692
- const scanScope = { namespaceKey, queryKey: snapshotOptions.queryKey };
3693
- const snapshot = useQuery<RightsizingScanResponse>(snapshotOptions);
3672
+ const { data: auth } = useAuthMe();
3673
+ const identity = JSON.stringify([auth?.username, [...(auth?.groups ?? [])].sort()]);
3674
+ const { namespaceKey, ...cache } = getRightsizingScanCacheConfig(namespaces, context, identity);
3675
+ const scope = JSON.stringify(cache.queryKey);
3676
+ const currentScope = useRef(scope);
3677
+ currentScope.current = scope;
3678
+ const previous = useRef<{ scope: string; result: RightsizingScanResponse } | null>(null);
3679
+ const params = new URLSearchParams();
3680
+ if (namespaceKey) params.set('namespaces', namespaceKey);
3681
+ const path = `/prometheus/rightsizing/scan?${params}`;
3682
+ const snapshot = useQuery<RightsizingScanResponse | null>({
3683
+ ...cache,
3684
+ enabled: Boolean(context && auth),
3685
+ queryFn: ({ signal }) => fetchJSON(path, { signal }),
3686
+ refetchOnMount: 'always',
3687
+ retry: (count, error) => !(error instanceof ApiError && [403, 404, 409].includes(error.status)) && count < 1,
3688
+ refetchInterval: (query) => !query.state.error && query.state.data?.scanStatus === 'running' ? 5000 : false,
3689
+ });
3694
3690
  const mutation = useMutation({
3695
- mutationFn: async (startedScope: typeof scanScope) => {
3696
- const params = new URLSearchParams();
3697
- if (startedScope.namespaceKey)
3698
- params.set("namespaces", startedScope.namespaceKey);
3699
- const query = params.toString();
3700
- return fetchJSON<RightsizingScanResponse>(
3701
- `/prometheus/rightsizing/scan${query ? `?${query}` : ""}`,
3702
- {
3703
- method: "POST",
3704
- },
3705
- );
3691
+ mutationFn: async (started: { scope: string; path: string; queryKey: typeof cache.queryKey; previousScanId?: string }) => {
3692
+ await queryClient.cancelQueries({ queryKey: started.queryKey });
3693
+ if (currentScope.current !== started.scope) throw new Error('Scan scope changed; run the scan again.');
3694
+ return fetchJSON<RightsizingScanResponse>(started.path, { method: 'POST' });
3695
+ },
3696
+ onSuccess: (result, started) => queryClient.setQueryData(started.queryKey, result),
3697
+ onError: (_error, started) => {
3698
+ // A lost POST response may still have started a scan. Read before retrying.
3699
+ void queryClient.invalidateQueries({ queryKey: started.queryKey });
3706
3700
  },
3707
- onSuccess: (result, startedScope) =>
3708
- queryClient.setQueryData(startedScope.queryKey, result),
3709
3701
  });
3702
+ const stop = useMutation({
3703
+ mutationFn: async (started: { id: string; scope: string; queryKey: typeof cache.queryKey }) => {
3704
+ await queryClient.cancelQueries({ queryKey: started.queryKey });
3705
+ if (currentScope.current !== started.scope) throw new Error('Scan scope changed.');
3706
+ return fetchJSON<RightsizingScanResponse>(`/prometheus/rightsizing/scan/${encodeURIComponent(started.id)}`, { method: 'DELETE' });
3707
+ },
3708
+ onSuccess: (result, started) => queryClient.setQueryData(started.queryKey, result),
3709
+ });
3710
+ const inaccessible = snapshot.error instanceof ApiError && [403, 404, 409].includes(snapshot.error.status);
3711
+ const current = inaccessible ? null : snapshot.data;
3712
+ useEffect(() => {
3713
+ if (inaccessible) {
3714
+ previous.current = null;
3715
+ queryClient.setQueryData(cache.queryKey, null);
3716
+ } else if (current && current.coverage.workloadsEvaluated > 0) {
3717
+ previous.current = { scope, result: current };
3718
+ }
3719
+ }, [current, inaccessible, scope, queryClient, cache.queryKey]);
3720
+ const showingPrevious = current?.scanStatus === 'running' && current.coverage.workloadsEvaluated === 0 && previous.current?.scope === scope;
3721
+ const startRecovered = current && current.scanId !== mutation.variables?.previousScanId;
3722
+ const stopRecovered = current && (current.scanId !== stop.variables?.id || current.scanStatus !== 'running');
3710
3723
  return {
3711
- ...mutation,
3712
- data: snapshot.data,
3713
- mutate: () => mutation.mutate(scanScope),
3714
- mutateAsync: () => mutation.mutateAsync(scanScope),
3724
+ data: showingPrevious ? previous.current!.result : current,
3725
+ progress: current,
3726
+ showingPrevious,
3727
+ isStarting: mutation.isPending,
3728
+ isPending: mutation.isPending || current?.scanStatus === 'running',
3729
+ isLoading: snapshot.isLoading,
3730
+ statusError: snapshot.error,
3731
+ error: snapshot.error || (stop.variables?.scope === scope && !stopRecovered ? stop.error : null) || (mutation.variables?.scope === scope && !startRecovered ? mutation.error : null),
3732
+ reset: () => { mutation.reset(); stop.reset(); },
3733
+ mutateAsync: () => mutation.mutateAsync({ scope, path, queryKey: cache.queryKey, previousScanId: current?.scanId }),
3734
+ retryStatus: () => snapshot.refetch(),
3735
+ stop: () => { if (!mutation.isPending && current?.scanStatus === 'running') stop.mutate({ id: current.scanId, scope, queryKey: cache.queryKey }); },
3736
+ isStopping: stop.isPending,
3715
3737
  };
3716
3738
  }
3717
3739
 
@@ -6443,6 +6465,18 @@ export function useContexts() {
6443
6465
  });
6444
6466
  }
6445
6467
 
6468
+ // Where a remote Argo Application or Flux object (spec.kubeConfig) deploys:
6469
+ // the destination's host for display, and the kubeconfig contexts that reach it.
6470
+ export function useGitOpsDestination(kind: string, namespace: string, name: string, enabled: boolean) {
6471
+ return useQuery<{ server: string; contexts: string[] }>({
6472
+ queryKey: ["gitops-destination", kind, namespace, name],
6473
+ queryFn: () => fetchJSON(`/gitops/destination/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`),
6474
+ enabled,
6475
+ staleTime: 60000,
6476
+ retry: false,
6477
+ });
6478
+ }
6479
+
6446
6480
  // Session counts for context switch confirmation
6447
6481
  export interface SessionCounts {
6448
6482
  portForwards: number;
@@ -6737,6 +6771,8 @@ export function useImageFilesystem(
6737
6771
  // Response from workload pods endpoint
6738
6772
  export interface WorkloadPodsResponse {
6739
6773
  pods: WorkloadPodInfo[];
6774
+ total: number;
6775
+ truncated: boolean;
6740
6776
  }
6741
6777
 
6742
6778
  // Response from workload logs endpoint (non-streaming)
@@ -6754,10 +6790,12 @@ export interface WorkloadLogsResponse {
6754
6790
  }
6755
6791
 
6756
6792
  export interface WorkloadRun {
6793
+ group: string;
6757
6794
  kind: string;
6758
6795
  namespace: string;
6759
6796
  name: string;
6760
6797
  phase: string;
6798
+ deleting?: boolean;
6761
6799
  active: boolean;
6762
6800
  startedAt?: string;
6763
6801
  finishedAt?: string;
@@ -6771,6 +6809,7 @@ export interface WorkloadRun {
6771
6809
  parallelism?: number;
6772
6810
  progress?: string;
6773
6811
  template?: string;
6812
+ jobset?: JobSetMember;
6774
6813
  launcher?: {
6775
6814
  kind: string;
6776
6815
  namespace?: string;
@@ -6784,17 +6823,51 @@ export interface WorkloadRun {
6784
6823
  podPending?: number;
6785
6824
  }
6786
6825
 
6826
+ export interface JobSetMember {
6827
+ replicatedJob?: string;
6828
+ replicatedJobReplicas?: string;
6829
+ jobIndex?: string;
6830
+ globalReplicas?: string;
6831
+ globalIndex?: string;
6832
+ groupName?: string;
6833
+ groupReplicas?: string;
6834
+ groupIndex?: string;
6835
+ restartAttempt?: string;
6836
+ jobRestartAttempt?: string;
6837
+ }
6838
+
6787
6839
  export interface WorkloadRunsResponse {
6840
+ collection: "runs" | "members";
6841
+ filteredTotal?: number;
6842
+ selected?: WorkloadRun;
6788
6843
  runs: WorkloadRun[];
6844
+ total: number;
6845
+ truncated: boolean;
6789
6846
  }
6790
6847
 
6791
6848
  // Fetch pods for a workload
6792
- export function useWorkloadPods(kind: string, namespace: string, name: string) {
6849
+ export function useWorkloadPods(
6850
+ kind: string,
6851
+ namespace: string,
6852
+ name: string,
6853
+ options?: { limit?: number; refetchInterval?: number | false; ownerUID?: string; nodeType?: string; workerGroup?: string },
6854
+ ) {
6855
+ const limit = options?.limit;
6856
+ const params = new URLSearchParams();
6857
+ if (limit) params.set('limit', String(limit));
6858
+ for (const key of ['ownerUID', 'nodeType', 'workerGroup'] as const) {
6859
+ if (options?.[key]) params.set(key, options[key]);
6860
+ }
6861
+ const queryString = params.size ? `?${params}` : '';
6862
+ const ownerKey = options?.ownerUID ? [options.ownerUID, options.nodeType ?? '', options.workerGroup ?? ''] : [];
6793
6863
  return useQuery<WorkloadPodsResponse>({
6794
- queryKey: ["workload-pods", kind, namespace, name],
6795
- queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/pods`),
6864
+ queryKey: ["workload-pods", kind, namespace, name, limit ?? 0, ...ownerKey],
6865
+ queryFn: () =>
6866
+ fetchJSON(`/workloads/${kind}/${namespace}/${name}/pods${queryString}`),
6796
6867
  enabled: Boolean(kind && namespace && name),
6797
6868
  staleTime: 10000, // 10 seconds - pods can change
6869
+ refetchInterval: options?.refetchInterval ?? false,
6870
+ retry: options?.ownerUID ? (count, error) => !(error instanceof ApiError && error.status === 409) && count < 2 : undefined,
6798
6871
  });
6799
6872
  }
6800
6873
 
@@ -6803,16 +6876,22 @@ export function useWorkloadRuns(
6803
6876
  namespace: string,
6804
6877
  name: string,
6805
6878
  enabled = true,
6806
- options?: { refetchActive?: boolean; clusterScoped?: boolean },
6879
+ options?: { refetchActive?: boolean; clusterScoped?: boolean; role?: string; search?: string; state?: string; selected?: string },
6807
6880
  ) {
6808
6881
  const clusterScoped = options?.clusterScoped ?? false;
6809
6882
  const ns = clusterScoped ? "_" : namespace;
6810
6883
  const params = new URLSearchParams();
6811
6884
  if (clusterScoped) params.set("clusterScoped", "true");
6885
+ for (const key of ["role", "search", "state", "selected"] as const) { if (options?.[key]) params.set(key, options[key]); }
6812
6886
  const queryString = params.toString();
6813
6887
 
6814
6888
  return useQuery<WorkloadRunsResponse>({
6815
- queryKey: ["workload-runs", kind, namespace, name, clusterScoped],
6889
+ queryKey: ["workload-runs", kind, namespace, name, clusterScoped, options?.role ?? '', options?.search ?? '', options?.state ?? '', options?.selected ?? ''],
6890
+ placeholderData: (previous, query) => {
6891
+ if (previous?.collection !== 'members') return undefined
6892
+ const identity = ["workload-runs", kind, namespace, name, clusterScoped, options?.role ?? '', options?.search ?? '', options?.state ?? '']
6893
+ return identity.every((value, index) => query?.queryKey[index] === value) ? previous : undefined
6894
+ },
6816
6895
  queryFn: () =>
6817
6896
  fetchJSON(
6818
6897
  `/workloads/${kind}/${ns}/${name}/runs${queryString ? `?${queryString}` : ""}`,
@@ -6821,11 +6900,26 @@ export function useWorkloadRuns(
6821
6900
  staleTime: 10000,
6822
6901
  refetchInterval: options?.refetchActive
6823
6902
  ? (query) =>
6824
- query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000
6903
+ query.state.data?.selected?.active || query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000
6825
6904
  : false,
6826
6905
  });
6827
6906
  }
6828
6907
 
6908
+ export function useKueueAdmission(namespace: string, name: string, uid: string | undefined) {
6909
+ return useQuery<KueueAdmissionResponse>({
6910
+ queryKey: ['kueue-admission', 'jobset.x-k8s.io', namespace, name, uid],
6911
+ queryFn: () => fetchJSON(`/kueue/admission/jobsets/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}?group=jobset.x-k8s.io`),
6912
+ enabled: Boolean(namespace && name && uid),
6913
+ staleTime: 5000,
6914
+ refetchInterval: (query) => {
6915
+ if (query.state.error instanceof ApiError && query.state.error.status < 500) return false
6916
+ if (query.state.data?.uid === uid && query.state.data?.installed === false) return false
6917
+ return 5000
6918
+ },
6919
+ retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2,
6920
+ })
6921
+ }
6922
+
6829
6923
  // Fetch logs for a workload (non-streaming)
6830
6924
  export function useWorkloadLogs(
6831
6925
  kind: string,
@@ -7119,3 +7213,64 @@ export function useDiagnostics(enabled: boolean) {
7119
7213
  gcTime: 0,
7120
7214
  });
7121
7215
  }
7216
+
7217
+ export interface JobSetUsage {
7218
+ runningPods: number
7219
+ reportingPods: number
7220
+ stalePods: number
7221
+ cpu: number | null
7222
+ memory: number | null
7223
+ cpuRequest: number
7224
+ memoryRequest: number
7225
+ observedAt?: string
7226
+ extendedRequests?: Record<string, string>
7227
+ }
7228
+ export interface JobSetResources {
7229
+ uid: string
7230
+ total: JobSetUsage
7231
+ members: Record<string, JobSetUsage>
7232
+ source: string
7233
+ unavailable?: string
7234
+ }
7235
+ export function useJobSetResources(namespace: string, name: string, uid: string | undefined, options: { role: string; search: string; state: string }) {
7236
+ const params = new URLSearchParams()
7237
+ for (const key of ["role", "search", "state"] as const) if (options[key]) params.set(key, options[key])
7238
+ return useQuery<JobSetResources>({
7239
+ queryKey: ['jobset-resources', namespace, name, uid, params.toString()],
7240
+ queryFn: ({ signal }) => fetchJSON(`/jobsets/${namespace}/${name}/resources?${params}`, { signal }),
7241
+ enabled: Boolean(namespace && name && uid),
7242
+ staleTime: 25000,
7243
+ refetchInterval: 30000,
7244
+ retry: false,
7245
+ })
7246
+ }
7247
+
7248
+
7249
+ export interface KueueProvisioningResponse {
7250
+ uid: string
7251
+ installed: boolean
7252
+ total: number
7253
+ truncated: boolean
7254
+ requests: Array<{
7255
+ apiVersion: string
7256
+ kind: string
7257
+ metadata: { name: string; namespace: string; uid: string; generation?: number; creationTimestamp?: string; deletionTimestamp?: string }
7258
+ spec: { provisioningClassName: string }
7259
+ status?: { conditions: Array<{ type: string; status: string; reason?: string; message?: string; observedGeneration?: number }> }
7260
+ }>
7261
+ }
7262
+
7263
+ export function useKueueProvisioning(namespace: string, name: string, uid: string | undefined, enabled: boolean) {
7264
+ return useQuery<KueueProvisioningResponse>({
7265
+ queryKey: ['kueue-provisioning', namespace, name, uid],
7266
+ queryFn: () => fetchJSON(`/kueue/provisioning/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`),
7267
+ enabled: enabled && Boolean(namespace && name && uid),
7268
+ staleTime: 5000,
7269
+ refetchInterval: query => {
7270
+ if (query.state.error instanceof ApiError && query.state.error.status < 500) return false
7271
+ if (query.state.data?.uid === uid && query.state.data?.installed === false) return false
7272
+ return 10000
7273
+ },
7274
+ retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2,
7275
+ })
7276
+ }