@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.
- package/README.md +2 -9
- package/package.json +6 -6
- package/src/App.tsx +142 -334
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +267 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +113 -63
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/SelfManagedStart.tsx +96 -0
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +18 -0
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +165 -0
- package/src/components/execution/JobSetAdmission.tsx +26 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
- package/src/components/home/HomeView.tsx +6 -2
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +27 -8
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/model.test.ts +2 -1
- package/src/components/rightsizing/notices.test.tsx +4 -3
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/SettingsDialog.tsx +168 -92
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +19 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +16 -0
- package/src/components/ui/UpdateNotification.tsx +13 -9
- package/src/components/ui/command-items.ts +14 -3
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +47 -19
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +34 -1
- package/src/utils/navigation.ts +28 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/utils/version.ts +11 -0
- 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
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
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 {
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
);
|
|
3692
|
-
const
|
|
3693
|
-
|
|
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 (
|
|
3696
|
-
|
|
3697
|
-
if (
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
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,
|
|
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
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
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(
|
|
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: () =>
|
|
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
|
+
}
|