@skyhook-io/radar-app 1.14.5 → 1.15.0

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 (117) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +142 -334
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.admission.test.ts +34 -0
  6. package/src/api/client.jobset.test.ts +18 -0
  7. package/src/api/client.resource-identity.test.ts +44 -0
  8. package/src/api/client.rightsizing.test.ts +7 -3
  9. package/src/api/client.ts +267 -78
  10. package/src/api/preferences.test.ts +54 -0
  11. package/src/api/preferences.ts +38 -0
  12. package/src/api/usage-data.fixtures.ts +33 -0
  13. package/src/api/usage-data.ts +165 -0
  14. package/src/components/CloudFunnelButton.test.tsx +155 -0
  15. package/src/components/CloudFunnelButton.tsx +113 -63
  16. package/src/components/ContextSwitcher.tsx +13 -132
  17. package/src/components/SelfManagedStart.tsx +96 -0
  18. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  19. package/src/components/applications/ApplicationsView.tsx +5 -3
  20. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  21. package/src/components/cloudConnectHandoff.test.ts +1 -1
  22. package/src/components/cloudConnectHandoff.ts +1 -1
  23. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  24. package/src/components/diagnose/AgentControls.tsx +26 -3
  25. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  26. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  27. package/src/components/diagnose/agentCatalog.ts +6 -0
  28. package/src/components/diagnose/launch.test.ts +23 -0
  29. package/src/components/diagnose/launch.ts +4 -0
  30. package/src/components/diagnose/parts.test.tsx +18 -0
  31. package/src/components/dialog-names.test.ts +69 -0
  32. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  33. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  34. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  35. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  36. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  37. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  38. package/src/components/execution/BatchExecutionView.tsx +324 -65
  39. package/src/components/execution/JobSetAdmission.test.tsx +165 -0
  40. package/src/components/execution/JobSetAdmission.tsx +26 -0
  41. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  42. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  43. package/src/components/execution/batch-run-actions.test.ts +1 -0
  44. package/src/components/execution/batch-timeline.test.ts +3 -0
  45. package/src/components/execution/execution-definition.test.ts +15 -1
  46. package/src/components/execution/execution-definition.ts +4 -0
  47. package/src/components/execution/member-collection.render.test.tsx +142 -0
  48. package/src/components/gitops/GitOpsView.tsx +33 -2
  49. package/src/components/gitops/destination-toast.test.ts +33 -0
  50. package/src/components/gitops/destination-toast.ts +46 -0
  51. package/src/components/gitops/useDestinationCluster.ts +30 -0
  52. package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
  53. package/src/components/home/HomeView.tsx +6 -2
  54. package/src/components/home/MCPSetupDialog.tsx +10 -0
  55. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  56. package/src/components/home/RadarVersionLine.tsx +28 -3
  57. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  58. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  59. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  60. package/src/components/nav/PrimaryNavRail.tsx +27 -8
  61. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  62. package/src/components/resource/PVCUsageBar.tsx +62 -16
  63. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  64. package/src/components/resources/ResourcesView.tsx +3 -3
  65. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  66. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  67. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  70. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  71. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  75. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  76. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  77. package/src/components/rightsizing/copy.test.ts +7 -0
  78. package/src/components/rightsizing/model.test.ts +2 -1
  79. package/src/components/rightsizing/notices.test.tsx +4 -3
  80. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  81. package/src/components/settings/PrivacySection.test.tsx +84 -0
  82. package/src/components/settings/PrivacySection.tsx +140 -0
  83. package/src/components/settings/SettingsDialog.tsx +168 -92
  84. package/src/components/settings/controls.tsx +55 -0
  85. package/src/components/settings/settings-state.test.ts +18 -0
  86. package/src/components/settings/settings-state.ts +19 -0
  87. package/src/components/timeline/TimelineView.tsx +9 -3
  88. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  89. package/src/components/ui/ErrorBoundary.tsx +5 -0
  90. package/src/components/ui/Omnibar.tsx +16 -0
  91. package/src/components/ui/UpdateNotification.tsx +13 -9
  92. package/src/components/ui/command-items.ts +14 -3
  93. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  94. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  95. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  96. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  97. package/src/components/useContextSwitchFlow.tsx +147 -0
  98. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  99. package/src/components/whats-new/WhatsNew.tsx +423 -0
  100. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  101. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  102. package/src/components/whats-new/releaseNotes.ts +121 -0
  103. package/src/components/workload/WorkloadView.test.ts +17 -1
  104. package/src/components/workload/WorkloadView.tsx +47 -19
  105. package/src/context/ContextSwitchContext.tsx +18 -2
  106. package/src/context/NavCustomization.tsx +4 -58
  107. package/src/context/ThemeContext.tsx +3 -11
  108. package/src/hooks/useClusterLoadState.ts +2 -2
  109. package/src/hooks/useFavorites.ts +3 -5
  110. package/src/k8s-ui-exports.test.ts +73 -0
  111. package/src/utils/auditBadges.ts +1 -1
  112. package/src/utils/navigation.test.ts +34 -1
  113. package/src/utils/navigation.ts +28 -5
  114. package/src/utils/topology-selection.test.ts +56 -0
  115. package/src/utils/topology-selection.ts +9 -9
  116. package/src/utils/version.ts +11 -0
  117. package/src/components/ui/CommandPalette.tsx +0 -261
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
  }
@@ -1597,6 +1604,37 @@ export function useClusterInfo() {
1597
1604
  export type InstallMethod =
1598
1605
  "homebrew" | "krew" | "scoop" | "direct" | "desktop";
1599
1606
 
1607
+ export interface WhatsNewState {
1608
+ currentVersion: string;
1609
+ // "server": seenVersion / priorInstall are authoritative (local installs).
1610
+ // "browser": the client keeps its own record (in-cluster).
1611
+ storage: "server" | "browser";
1612
+ seenVersion?: string;
1613
+ priorInstall?: boolean;
1614
+ }
1615
+
1616
+ export function useWhatsNewState(enabled: boolean) {
1617
+ return useQuery<WhatsNewState>({
1618
+ queryKey: ["whats-new", getApiBase()],
1619
+ queryFn: () => fetchJSON("/whats-new"),
1620
+ enabled,
1621
+ // Refetched on focus (the app default is off), so another tab's
1622
+ // acknowledgment clears this tab's dot.
1623
+ staleTime: 0,
1624
+ refetchOnWindowFocus: true,
1625
+ retry: false,
1626
+ });
1627
+ }
1628
+
1629
+ export async function markWhatsNewSeen(version: string): Promise<void> {
1630
+ const response = await apiFetch(`${getApiBase()}/whats-new/seen`, {
1631
+ method: "POST",
1632
+ headers: { "Content-Type": "application/json" },
1633
+ body: JSON.stringify({ version }),
1634
+ });
1635
+ if (!response.ok) throw new ApiError(`HTTP ${response.status}`, response.status);
1636
+ }
1637
+
1600
1638
  export interface VersionInfo {
1601
1639
  currentVersion: string;
1602
1640
  latestVersion?: string;
@@ -2808,30 +2846,6 @@ export function useChanges(options: UseChangesOptions = {}) {
2808
2846
  });
2809
2847
  }
2810
2848
 
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
2849
  export interface ResourceEventsResult {
2836
2850
  k8sEvents: TimelineEvent[];
2837
2851
  updates: TimelineEvent[];
@@ -2850,10 +2864,12 @@ export function useResourceEvents(
2850
2864
  kind: string,
2851
2865
  namespace: string,
2852
2866
  name: string,
2867
+ group?: string,
2853
2868
  ): ResourceEventsResult {
2854
2869
  // The timeline store keys events by their K8s Kind (singular PascalCase, e.g. "Pod"),
2855
2870
  // but callers pass the URL-form kind ("pods").
2856
- const singularKind = pluralToKind(kind);
2871
+ const singularKind = knownKindForPluralWithGroup(kind, group ?? "") ?? pluralToKind(kind);
2872
+ const resolvedGroup = canonicalResourceGroup(singularKind, group);
2857
2873
  const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
2858
2874
 
2859
2875
  // Include managed resources — when viewing a specific resource (e.g. a Pod owned
@@ -2864,18 +2880,19 @@ export function useResourceEvents(
2864
2880
  p.set("namespace", namespace);
2865
2881
  p.set("kind", singularKind);
2866
2882
  p.set("name", name);
2883
+ if (resolvedGroup !== undefined) p.set("group", resolvedGroup);
2867
2884
  p.set("include_managed", "true");
2868
2885
  p.set("since", since);
2869
2886
  return p;
2870
2887
  };
2871
2888
 
2872
- const enabled = Boolean(kind && namespace && name);
2889
+ const enabled = Boolean(kind && namespace && name && resolvedGroup !== undefined);
2873
2890
 
2874
2891
  // K8s events: high limit so the full set is always returned. The number of
2875
2892
  // distinct K8s events per resource is naturally bounded — kubelet/controllers
2876
2893
  // dedupe via Reason+InvolvedObject and bump count.
2877
2894
  const k8sQuery = useQuery<TimelineEvent[]>({
2878
- queryKey: ["resource-events", "k8s", singularKind, namespace, name],
2895
+ queryKey: ["resource-events", "k8s", singularKind, resolvedGroup, namespace, name],
2879
2896
  queryFn: async () => {
2880
2897
  const params = baseParams();
2881
2898
  params.set("sources", "k8s_event");
@@ -2889,7 +2906,7 @@ export function useResourceEvents(
2889
2906
  // Resource updates (informer diffs + historical): bounded so a flapping
2890
2907
  // resource doesn't return an unbounded payload.
2891
2908
  const updatesQuery = useQuery<TimelineEvent[]>({
2892
- queryKey: ["resource-events", "updates", singularKind, namespace, name],
2909
+ queryKey: ["resource-events", "updates", singularKind, resolvedGroup, namespace, name],
2893
2910
  queryFn: async () => {
2894
2911
  const params = baseParams();
2895
2912
  params.set("sources", "informer,historical");
@@ -3272,9 +3289,9 @@ export type PrometheusTimeRange =
3272
3289
  "10m" | "30m" | "1h" | "3h" | "6h" | "12h" | "24h" | "48h" | "7d" | "14d";
3273
3290
 
3274
3291
  // 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
3292
  export interface PrometheusPVCUsage {
3293
+ // Hub packages the frontend independently from per-cluster agent upgrades.
3294
+ status?: "available" | "no_series" | "invalid_data" | "query_failed";
3278
3295
  namespace: string;
3279
3296
  name: string;
3280
3297
  used: number;
@@ -3364,6 +3381,7 @@ export interface RightsizingScanCoverage {
3364
3381
  workloadsWithData: number;
3365
3382
  batches: number;
3366
3383
  completedBatches: number;
3384
+ attemptedBatches: number;
3367
3385
  restrictedKinds?: string[];
3368
3386
  unavailableKinds?: string[];
3369
3387
  partiallyCachedKinds?: string[];
@@ -3371,6 +3389,11 @@ export interface RightsizingScanCoverage {
3371
3389
  }
3372
3390
 
3373
3391
  export interface RightsizingScanResponse {
3392
+ scanId: string;
3393
+ scanStatus: 'running' | 'finished' | 'cancelled' | 'timed_out';
3394
+ deadlineAt: string;
3395
+ expiresAt?: string;
3396
+ pollAfterSeconds?: number;
3374
3397
  state: RightsizingScanState;
3375
3398
  scannedAt: string;
3376
3399
  window: string;
@@ -3629,7 +3652,6 @@ export function usePrometheusClusterMetrics(
3629
3652
  });
3630
3653
  }
3631
3654
 
3632
- // Fetch PVC usage. hasData=false when no series — UI should hide the gauge.
3633
3655
  export function usePrometheusPVCUsage(
3634
3656
  namespace: string,
3635
3657
  name: string,
@@ -3640,7 +3662,10 @@ export function usePrometheusPVCUsage(
3640
3662
  queryFn: () => fetchJSON(`/prometheus/pvc/${namespace}/${name}`),
3641
3663
  enabled: enabled && Boolean(namespace && name),
3642
3664
  staleTime: 60000,
3643
- refetchInterval: 120000,
3665
+ refetchInterval: (query) =>
3666
+ isForbiddenError(query.state.error) ? false : 120000,
3667
+ retry: (failureCount, error) =>
3668
+ !isForbiddenError(error) && failureCount < 1,
3644
3669
  });
3645
3670
  }
3646
3671
 
@@ -3663,55 +3688,83 @@ export function usePrometheusRightsizing(
3663
3688
 
3664
3689
  const RIGHTSIZING_SCAN_CACHE_TIME = 5 * 60 * 1000;
3665
3690
 
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(",");
3691
+ export function getRightsizingScanCacheConfig(namespaces: string[], context = "", identity = "") {
3692
+ const namespaceKey = [...new Set(namespaces)].sort().join(",");
3676
3693
  return {
3677
3694
  namespaceKey,
3678
- queryKey: ["prometheus-rightsizing-scan", context, namespaceKey] as const,
3679
- queryFn: skipToken,
3695
+ queryKey: ["prometheus-rightsizing-scan", getApiBase(), identity, context, namespaceKey] as const,
3680
3696
  gcTime: RIGHTSIZING_SCAN_CACHE_TIME,
3681
3697
  };
3682
3698
  }
3683
3699
 
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.
3700
+ // Navigation retrieves a retained scan; only an explicit POST starts work.
3686
3701
  export function useRightsizingScan(namespaces: string[], context = "") {
3687
3702
  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);
3703
+ const { data: auth } = useAuthMe();
3704
+ const identity = JSON.stringify([auth?.username, [...(auth?.groups ?? [])].sort()]);
3705
+ const { namespaceKey, ...cache } = getRightsizingScanCacheConfig(namespaces, context, identity);
3706
+ const scope = JSON.stringify(cache.queryKey);
3707
+ const currentScope = useRef(scope);
3708
+ currentScope.current = scope;
3709
+ const previous = useRef<{ scope: string; result: RightsizingScanResponse } | null>(null);
3710
+ const params = new URLSearchParams();
3711
+ if (namespaceKey) params.set('namespaces', namespaceKey);
3712
+ const path = `/prometheus/rightsizing/scan?${params}`;
3713
+ const snapshot = useQuery<RightsizingScanResponse | null>({
3714
+ ...cache,
3715
+ enabled: Boolean(context && auth),
3716
+ queryFn: ({ signal }) => fetchJSON(path, { signal }),
3717
+ refetchOnMount: 'always',
3718
+ retry: (count, error) => !(error instanceof ApiError && [403, 404, 409].includes(error.status)) && count < 1,
3719
+ refetchInterval: (query) => !query.state.error && query.state.data?.scanStatus === 'running' ? 5000 : false,
3720
+ });
3694
3721
  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
- );
3722
+ mutationFn: async (started: { scope: string; path: string; queryKey: typeof cache.queryKey; previousScanId?: string }) => {
3723
+ await queryClient.cancelQueries({ queryKey: started.queryKey });
3724
+ if (currentScope.current !== started.scope) throw new Error('Scan scope changed; run the scan again.');
3725
+ return fetchJSON<RightsizingScanResponse>(started.path, { method: 'POST' });
3726
+ },
3727
+ onSuccess: (result, started) => queryClient.setQueryData(started.queryKey, result),
3728
+ onError: (_error, started) => {
3729
+ // A lost POST response may still have started a scan. Read before retrying.
3730
+ void queryClient.invalidateQueries({ queryKey: started.queryKey });
3731
+ },
3732
+ });
3733
+ const stop = useMutation({
3734
+ mutationFn: async (started: { id: string; scope: string; queryKey: typeof cache.queryKey }) => {
3735
+ await queryClient.cancelQueries({ queryKey: started.queryKey });
3736
+ if (currentScope.current !== started.scope) throw new Error('Scan scope changed.');
3737
+ return fetchJSON<RightsizingScanResponse>(`/prometheus/rightsizing/scan/${encodeURIComponent(started.id)}`, { method: 'DELETE' });
3706
3738
  },
3707
- onSuccess: (result, startedScope) =>
3708
- queryClient.setQueryData(startedScope.queryKey, result),
3739
+ onSuccess: (result, started) => queryClient.setQueryData(started.queryKey, result),
3709
3740
  });
3741
+ const inaccessible = snapshot.error instanceof ApiError && [403, 404, 409].includes(snapshot.error.status);
3742
+ const current = inaccessible ? null : snapshot.data;
3743
+ useEffect(() => {
3744
+ if (inaccessible) {
3745
+ previous.current = null;
3746
+ queryClient.setQueryData(cache.queryKey, null);
3747
+ } else if (current && current.coverage.workloadsEvaluated > 0) {
3748
+ previous.current = { scope, result: current };
3749
+ }
3750
+ }, [current, inaccessible, scope, queryClient, cache.queryKey]);
3751
+ const showingPrevious = current?.scanStatus === 'running' && current.coverage.workloadsEvaluated === 0 && previous.current?.scope === scope;
3752
+ const startRecovered = current && current.scanId !== mutation.variables?.previousScanId;
3753
+ const stopRecovered = current && (current.scanId !== stop.variables?.id || current.scanStatus !== 'running');
3710
3754
  return {
3711
- ...mutation,
3712
- data: snapshot.data,
3713
- mutate: () => mutation.mutate(scanScope),
3714
- mutateAsync: () => mutation.mutateAsync(scanScope),
3755
+ data: showingPrevious ? previous.current!.result : current,
3756
+ progress: current,
3757
+ showingPrevious,
3758
+ isStarting: mutation.isPending,
3759
+ isPending: mutation.isPending || current?.scanStatus === 'running',
3760
+ isLoading: snapshot.isLoading,
3761
+ statusError: snapshot.error,
3762
+ error: snapshot.error || (stop.variables?.scope === scope && !stopRecovered ? stop.error : null) || (mutation.variables?.scope === scope && !startRecovered ? mutation.error : null),
3763
+ reset: () => { mutation.reset(); stop.reset(); },
3764
+ mutateAsync: () => mutation.mutateAsync({ scope, path, queryKey: cache.queryKey, previousScanId: current?.scanId }),
3765
+ retryStatus: () => snapshot.refetch(),
3766
+ stop: () => { if (!mutation.isPending && current?.scanStatus === 'running') stop.mutate({ id: current.scanId, scope, queryKey: cache.queryKey }); },
3767
+ isStopping: stop.isPending,
3715
3768
  };
3716
3769
  }
3717
3770
 
@@ -6443,6 +6496,18 @@ export function useContexts() {
6443
6496
  });
6444
6497
  }
6445
6498
 
6499
+ // Where a remote Argo Application or Flux object (spec.kubeConfig) deploys:
6500
+ // the destination's host for display, and the kubeconfig contexts that reach it.
6501
+ export function useGitOpsDestination(kind: string, namespace: string, name: string, enabled: boolean) {
6502
+ return useQuery<{ server: string; contexts: string[] }>({
6503
+ queryKey: ["gitops-destination", kind, namespace, name],
6504
+ queryFn: () => fetchJSON(`/gitops/destination/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`),
6505
+ enabled,
6506
+ staleTime: 60000,
6507
+ retry: false,
6508
+ });
6509
+ }
6510
+
6446
6511
  // Session counts for context switch confirmation
6447
6512
  export interface SessionCounts {
6448
6513
  portForwards: number;
@@ -6737,6 +6802,8 @@ export function useImageFilesystem(
6737
6802
  // Response from workload pods endpoint
6738
6803
  export interface WorkloadPodsResponse {
6739
6804
  pods: WorkloadPodInfo[];
6805
+ total: number;
6806
+ truncated: boolean;
6740
6807
  }
6741
6808
 
6742
6809
  // Response from workload logs endpoint (non-streaming)
@@ -6754,10 +6821,12 @@ export interface WorkloadLogsResponse {
6754
6821
  }
6755
6822
 
6756
6823
  export interface WorkloadRun {
6824
+ group: string;
6757
6825
  kind: string;
6758
6826
  namespace: string;
6759
6827
  name: string;
6760
6828
  phase: string;
6829
+ deleting?: boolean;
6761
6830
  active: boolean;
6762
6831
  startedAt?: string;
6763
6832
  finishedAt?: string;
@@ -6771,6 +6840,7 @@ export interface WorkloadRun {
6771
6840
  parallelism?: number;
6772
6841
  progress?: string;
6773
6842
  template?: string;
6843
+ jobset?: JobSetMember;
6774
6844
  launcher?: {
6775
6845
  kind: string;
6776
6846
  namespace?: string;
@@ -6784,17 +6854,51 @@ export interface WorkloadRun {
6784
6854
  podPending?: number;
6785
6855
  }
6786
6856
 
6857
+ export interface JobSetMember {
6858
+ replicatedJob?: string;
6859
+ replicatedJobReplicas?: string;
6860
+ jobIndex?: string;
6861
+ globalReplicas?: string;
6862
+ globalIndex?: string;
6863
+ groupName?: string;
6864
+ groupReplicas?: string;
6865
+ groupIndex?: string;
6866
+ restartAttempt?: string;
6867
+ jobRestartAttempt?: string;
6868
+ }
6869
+
6787
6870
  export interface WorkloadRunsResponse {
6871
+ collection: "runs" | "members";
6872
+ filteredTotal?: number;
6873
+ selected?: WorkloadRun;
6788
6874
  runs: WorkloadRun[];
6875
+ total: number;
6876
+ truncated: boolean;
6789
6877
  }
6790
6878
 
6791
6879
  // Fetch pods for a workload
6792
- export function useWorkloadPods(kind: string, namespace: string, name: string) {
6880
+ export function useWorkloadPods(
6881
+ kind: string,
6882
+ namespace: string,
6883
+ name: string,
6884
+ options?: { limit?: number; refetchInterval?: number | false; ownerUID?: string; nodeType?: string; workerGroup?: string },
6885
+ ) {
6886
+ const limit = options?.limit;
6887
+ const params = new URLSearchParams();
6888
+ if (limit) params.set('limit', String(limit));
6889
+ for (const key of ['ownerUID', 'nodeType', 'workerGroup'] as const) {
6890
+ if (options?.[key]) params.set(key, options[key]);
6891
+ }
6892
+ const queryString = params.size ? `?${params}` : '';
6893
+ const ownerKey = options?.ownerUID ? [options.ownerUID, options.nodeType ?? '', options.workerGroup ?? ''] : [];
6793
6894
  return useQuery<WorkloadPodsResponse>({
6794
- queryKey: ["workload-pods", kind, namespace, name],
6795
- queryFn: () => fetchJSON(`/workloads/${kind}/${namespace}/${name}/pods`),
6895
+ queryKey: ["workload-pods", kind, namespace, name, limit ?? 0, ...ownerKey],
6896
+ queryFn: () =>
6897
+ fetchJSON(`/workloads/${kind}/${namespace}/${name}/pods${queryString}`),
6796
6898
  enabled: Boolean(kind && namespace && name),
6797
6899
  staleTime: 10000, // 10 seconds - pods can change
6900
+ refetchInterval: options?.refetchInterval ?? false,
6901
+ retry: options?.ownerUID ? (count, error) => !(error instanceof ApiError && error.status === 409) && count < 2 : undefined,
6798
6902
  });
6799
6903
  }
6800
6904
 
@@ -6803,16 +6907,22 @@ export function useWorkloadRuns(
6803
6907
  namespace: string,
6804
6908
  name: string,
6805
6909
  enabled = true,
6806
- options?: { refetchActive?: boolean; clusterScoped?: boolean },
6910
+ options?: { refetchActive?: boolean; clusterScoped?: boolean; role?: string; search?: string; state?: string; selected?: string },
6807
6911
  ) {
6808
6912
  const clusterScoped = options?.clusterScoped ?? false;
6809
6913
  const ns = clusterScoped ? "_" : namespace;
6810
6914
  const params = new URLSearchParams();
6811
6915
  if (clusterScoped) params.set("clusterScoped", "true");
6916
+ for (const key of ["role", "search", "state", "selected"] as const) { if (options?.[key]) params.set(key, options[key]); }
6812
6917
  const queryString = params.toString();
6813
6918
 
6814
6919
  return useQuery<WorkloadRunsResponse>({
6815
- queryKey: ["workload-runs", kind, namespace, name, clusterScoped],
6920
+ queryKey: ["workload-runs", kind, namespace, name, clusterScoped, options?.role ?? '', options?.search ?? '', options?.state ?? '', options?.selected ?? ''],
6921
+ placeholderData: (previous, query) => {
6922
+ if (previous?.collection !== 'members') return undefined
6923
+ const identity = ["workload-runs", kind, namespace, name, clusterScoped, options?.role ?? '', options?.search ?? '', options?.state ?? '']
6924
+ return identity.every((value, index) => query?.queryKey[index] === value) ? previous : undefined
6925
+ },
6816
6926
  queryFn: () =>
6817
6927
  fetchJSON(
6818
6928
  `/workloads/${kind}/${ns}/${name}/runs${queryString ? `?${queryString}` : ""}`,
@@ -6821,11 +6931,29 @@ export function useWorkloadRuns(
6821
6931
  staleTime: 10000,
6822
6932
  refetchInterval: options?.refetchActive
6823
6933
  ? (query) =>
6824
- query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000
6934
+ query.state.data?.selected?.active || query.state.data?.runs?.some((run) => run.active) ? 5000 : 30000
6825
6935
  : false,
6826
6936
  });
6827
6937
  }
6828
6938
 
6939
+ export function useKueueAdmission(namespace: string, name: string, uid: string | undefined, options?: { isJob?: boolean; isRayJob?: boolean; hinted?: boolean; terminal?: boolean }) {
6940
+ const group = options?.isRayJob ? 'ray.io' : options?.isJob ? 'batch' : 'jobset.x-k8s.io'
6941
+ const kind = options?.isRayJob ? 'rayjobs' : options?.isJob ? 'jobs' : 'jobsets'
6942
+ return useQuery<KueueAdmissionResponse>({
6943
+ queryKey: ['kueue-admission', group, kind, namespace, name, uid],
6944
+ queryFn: () => fetchJSON(`/kueue/admission/${kind}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}?group=${group}`),
6945
+ enabled: Boolean(namespace && name && uid),
6946
+ staleTime: 5000,
6947
+ refetchInterval: (query) => {
6948
+ if (query.state.error instanceof ApiError && query.state.error.status < 500) return false
6949
+ if (query.state.data?.uid === uid && query.state.data?.installed === false) return false
6950
+ if ((options?.isJob || options?.isRayJob) && (options.terminal || (!options.hinted && !query.state.data?.workloads.length))) return 30000
6951
+ return 5000
6952
+ },
6953
+ retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2,
6954
+ })
6955
+ }
6956
+
6829
6957
  // Fetch logs for a workload (non-streaming)
6830
6958
  export function useWorkloadLogs(
6831
6959
  kind: string,
@@ -7119,3 +7247,64 @@ export function useDiagnostics(enabled: boolean) {
7119
7247
  gcTime: 0,
7120
7248
  });
7121
7249
  }
7250
+
7251
+ export interface JobSetUsage {
7252
+ runningPods: number
7253
+ reportingPods: number
7254
+ stalePods: number
7255
+ cpu: number | null
7256
+ memory: number | null
7257
+ cpuRequest: number
7258
+ memoryRequest: number
7259
+ observedAt?: string
7260
+ extendedRequests?: Record<string, string>
7261
+ }
7262
+ export interface JobSetResources {
7263
+ uid: string
7264
+ total: JobSetUsage
7265
+ members: Record<string, JobSetUsage>
7266
+ source: string
7267
+ unavailable?: string
7268
+ }
7269
+ export function useJobSetResources(namespace: string, name: string, uid: string | undefined, options: { role: string; search: string; state: string }) {
7270
+ const params = new URLSearchParams()
7271
+ for (const key of ["role", "search", "state"] as const) if (options[key]) params.set(key, options[key])
7272
+ return useQuery<JobSetResources>({
7273
+ queryKey: ['jobset-resources', namespace, name, uid, params.toString()],
7274
+ queryFn: ({ signal }) => fetchJSON(`/jobsets/${namespace}/${name}/resources?${params}`, { signal }),
7275
+ enabled: Boolean(namespace && name && uid),
7276
+ staleTime: 25000,
7277
+ refetchInterval: 30000,
7278
+ retry: false,
7279
+ })
7280
+ }
7281
+
7282
+
7283
+ export interface KueueProvisioningResponse {
7284
+ uid: string
7285
+ installed: boolean
7286
+ total: number
7287
+ truncated: boolean
7288
+ requests: Array<{
7289
+ apiVersion: string
7290
+ kind: string
7291
+ metadata: { name: string; namespace: string; uid: string; generation?: number; creationTimestamp?: string; deletionTimestamp?: string }
7292
+ spec: { provisioningClassName: string }
7293
+ status?: { conditions: Array<{ type: string; status: string; reason?: string; message?: string; observedGeneration?: number }> }
7294
+ }>
7295
+ }
7296
+
7297
+ export function useKueueProvisioning(namespace: string, name: string, uid: string | undefined, enabled: boolean) {
7298
+ return useQuery<KueueProvisioningResponse>({
7299
+ queryKey: ['kueue-provisioning', namespace, name, uid],
7300
+ queryFn: () => fetchJSON(`/kueue/provisioning/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`),
7301
+ enabled: enabled && Boolean(namespace && name && uid),
7302
+ staleTime: 5000,
7303
+ refetchInterval: query => {
7304
+ if (query.state.error instanceof ApiError && query.state.error.status < 500) return false
7305
+ if (query.state.data?.uid === uid && query.state.data?.installed === false) return false
7306
+ return 10000
7307
+ },
7308
+ retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2,
7309
+ })
7310
+ }
@@ -0,0 +1,54 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ describe('preference persistence by installation', () => {
4
+ beforeEach(() => { vi.resetModules() })
5
+ afterEach(() => { vi.unstubAllGlobals() })
6
+
7
+ it('keeps shared OSS theme and pins in the browser without writing the server', async () => {
8
+ const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ preferenceStorage: 'browser' })))
9
+ vi.stubGlobal('fetch', fetchMock)
10
+ const { persistPreferences } = await import('./preferences')
11
+ await persistPreferences({ theme: 'light' })
12
+ await persistPreferences({ pinnedKinds: [{ name: 'pods', kind: 'Pod', group: '' }] })
13
+ expect(fetchMock).toHaveBeenCalledTimes(1)
14
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/settings')
15
+ expect(fetchMock.mock.calls[0][1].method).toBeUndefined()
16
+ })
17
+
18
+ it('preserves local and Cloud persistence through configured API helpers', async () => {
19
+ const fetchMock = vi.fn().mockImplementation(async () => new Response('{}'))
20
+ vi.stubGlobal('fetch', fetchMock)
21
+ const config = await import('./config')
22
+ config.setApiBase('/c/example/api')
23
+ config.setAuthHeadersProvider(() => ({ Authorization: 'test-header' }))
24
+ config.setCredentialsMode('include')
25
+ const { persistPreferences } = await import('./preferences')
26
+ await persistPreferences({ theme: 'light' })
27
+ expect(fetchMock).toHaveBeenLastCalledWith('/c/example/api/settings', expect.objectContaining({
28
+ method: 'PUT', credentials: 'include',
29
+ headers: { 'Content-Type': 'application/json', Authorization: 'test-header' },
30
+ body: JSON.stringify({ theme: 'light' }),
31
+ }))
32
+ })
33
+
34
+ it('never assumes permission to write when the settings read fails', async () => {
35
+ const fetchMock = vi.fn().mockResolvedValue(new Response('denied', { status: 403 }))
36
+ vi.stubGlobal('fetch', fetchMock)
37
+ const { persistPreferences } = await import('./preferences')
38
+ await expect(persistPreferences({ theme: 'light' })).rejects.toThrow('403')
39
+ expect(fetchMock).toHaveBeenCalledTimes(1)
40
+ })
41
+
42
+ it('does not send a pending change to another instance after navigation', async () => {
43
+ let finish!: (response: Response) => void
44
+ const fetchMock = vi.fn().mockReturnValue(new Promise<Response>((resolve) => { finish = resolve }))
45
+ vi.stubGlobal('fetch', fetchMock)
46
+ const config = await import('./config')
47
+ const { persistPreferences } = await import('./preferences')
48
+ const saving = persistPreferences({ theme: 'light' })
49
+ config.setApiBase('/c/other/api')
50
+ finish(new Response('{}'))
51
+ await saving
52
+ expect(fetchMock).toHaveBeenCalledTimes(1)
53
+ })
54
+ })
@@ -0,0 +1,38 @@
1
+ import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode } from './config'
2
+ import type { PinnedKind } from '../hooks/useFavorites'
3
+
4
+ interface Preferences {
5
+ theme?: 'light' | 'dark'
6
+ pinnedKinds?: PinnedKind[]
7
+ preferenceStorage?: 'browser'
8
+ }
9
+
10
+ let request: { base: string; promise: Promise<Preferences> } | undefined
11
+
12
+ export function loadPreferences(): Promise<Preferences> {
13
+ const base = getApiBase()
14
+ if (!request || request.base !== base) {
15
+ const promise = fetch(apiUrl('/settings'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
16
+ .then((res) => {
17
+ if (!res.ok) throw new Error(`Failed to load preferences: HTTP ${res.status}`)
18
+ return res.json() as Promise<Preferences>
19
+ })
20
+ request = { base, promise }
21
+ promise.catch(() => { if (request?.promise === promise) request = undefined })
22
+ }
23
+ return request.promise
24
+ }
25
+
26
+ export async function persistPreferences(patch: Pick<Preferences, 'theme' | 'pinnedKinds'>): Promise<void> {
27
+ const base = getApiBase()
28
+ const preferences = await loadPreferences()
29
+ if (base !== getApiBase() || preferences.preferenceStorage === 'browser') return
30
+ const res = await fetch(apiUrl('/settings'), {
31
+ method: 'PUT',
32
+ credentials: getCredentialsMode(),
33
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
34
+ body: JSON.stringify(patch),
35
+ })
36
+ if (!res.ok) throw new Error(`Failed to save preferences: HTTP ${res.status}`)
37
+ if (request?.base === base) request = undefined
38
+ }
@@ -0,0 +1,33 @@
1
+ import type { UsageDataStatus, UsageReport } from './usage-data'
2
+
3
+ // Test-only: a report and status shaped like the server's.
4
+ export function exampleReport(patch: Partial<UsageReport> = {}): UsageReport {
5
+ return {
6
+ schema: 3,
7
+ version: '1.15.0', os: 'darwin', arch: 'arm64', installMethod: 'homebrew', mode: 'local',
8
+ periodStart: '2026-09-23', periodEnd: '2026-09-24',
9
+ setup: {
10
+ authMode: 'none', timelineStorage: 'memory', mcpEnabled: true, prometheus: 'none',
11
+ costSource: 'auto', browsers: ['chrome'],
12
+ },
13
+ engagement: { sessions: 2, activeMinutes: '20-49' },
14
+ views: {}, actions: {}, mcpTools: {}, uiEvents: {}, errors: {},
15
+ clusters: {
16
+ contexts: '5-9', used: 1,
17
+ shapes: [{ kubernetesVersion: '1.33', platform: 'eks', nodes: '10-19', integrations: ['argo-cd'] }],
18
+ },
19
+ ...patch,
20
+ }
21
+ }
22
+
23
+ export function exampleStatus(patch: Partial<UsageDataStatus> = {}): UsageDataStatus {
24
+ return {
25
+ state: 'undecided', source: 'default', canChange: true,
26
+ developmentBuild: false,
27
+ preview: exampleReport(),
28
+ firstRunPrompt: false,
29
+ ask: true,
30
+ shared: false,
31
+ ...patch,
32
+ }
33
+ }