Skip to content

Commit 9cffafb

Browse files
gnugomeznetomi
andauthored
feat: migrate admin pages to TanStack Query (#1917)
* feat: migrate admin settings to TanStack Query Assisted-By: anthropic:claude-opus-4-7[1m], anthropic:claude-opus-4-8[1m] * feat: migrate admin logs to TanStack Query Assisted-By: anthropic:claude-opus-4-7[1m], anthropic:claude-opus-4-8[1m] * feat: migrate admin tiers to TanStack Query Assisted-By: anthropic:claude-opus-4-7[1m], anthropic:claude-opus-4-8[1m] * feat: migrate admin customers to TanStack Query Assisted-By: anthropic:claude-opus-4-7[1m], anthropic:claude-opus-4-8[1m] * feat: migrate admin usage-stats to TanStack Query Assisted-By: anthropic:claude-opus-4-7[1m], anthropic:claude-opus-4-8[1m] * feat: migrate admin namespace pages to TanStack Query Assisted-By: anthropic:claude-opus-4-7[1m], anthropic:claude-opus-4-8[1m] * feat: migrate admin publisher pages to TanStack Query Assisted-By: anthropic:claude-opus-4-7[1m], anthropic:claude-opus-4-8[1m] * feat: migrate admin extension pages to TanStack Query Assisted-By: anthropic:claude-opus-4-7[1m], anthropic:claude-opus-4-8[1m] * feat: migrate admin scan pages to TanStack Query Assisted-By: anthropic:claude-opus-4-7[1m], anthropic:claude-opus-4-8[1m] * refactor: destructure mutation hooks at call sites Assisted-By: anthropic:claude-opus-4-7[1m], anthropic:claude-opus-4-8[1m] * refactor: drop AbortController from mutating admin methods; retries now owned by TanStack Query Assisted-By: anthropic:claude-opus-4-7[1m], anthropic:claude-opus-4-8[1m] * add changelog entry * simplify devtools integration * formatting --------- Co-authored-by: Thomas Neidhart <thomas.neidhart@eclipse-foundation.org>
1 parent 33142e9 commit 9cffafb

37 files changed

Lines changed: 1585 additions & 1164 deletions

webui/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ This change log covers only the frontend library (webui) of Open VSX.
44

55
## [next] (unreleased)
66

7+
### Changed
8+
9+
- Migrate admin dashboard to use `@tanstack/react-query` ([#1917](https://github.com/eclipse-openvsx/openvsx/pull/1917)
710

811
## [v1.0.2] (23/06/2026)
912

webui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
"@mui/x-charts": "^8.28.2",
5050
"@mui/x-data-grid": "^8.28.2",
5151
"@mui/x-date-pickers": "^8.27.2",
52+
"@tanstack/react-query": "^5.101.0",
53+
"@tanstack/react-query-devtools": "^5.101.0",
5254
"clipboard-copy": "^4.0.1",
5355
"clsx": "^1.2.1",
5456
"dompurify": "^3.0.4",

webui/src/components/rate-limiting/usage-stats/use-usage-stats.ts

Lines changed: 22 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -11,63 +11,34 @@
1111
* SPDX-License-Identifier: EPL-2.0
1212
*****************************************************************************/
1313

14-
import { useContext, useState, useEffect, useRef, useCallback } from "react";
14+
import { useContext, useState } from "react";
15+
import { useQuery } from "@tanstack/react-query";
1516
import { MainContext } from "../../../context";
1617
import type { UsageStats } from "../../../extension-registry-types";
1718
import { handleError } from "../../../utils";
1819
import { getDefaultStartDate } from "./usage-stats-utils";
20+
import { controllerFromSignal } from "../../../query-client";
1921
import { DateTime } from "luxon";
2022

23+
// Stable empty reference so consumers don't see a new array identity on every render.
24+
const NO_STATS: readonly UsageStats[] = [];
25+
2126
export const useUsageStats = (customerName: string | undefined) => {
22-
const abortController = useRef(new AbortController());
2327
const { service } = useContext(MainContext);
24-
25-
const [usageStats, setUsageStats] = useState<readonly UsageStats[]>([]);
26-
const [dailyP95, setDailyP95] = useState<number | undefined>(undefined);
27-
const [loading, setLoading] = useState(false);
28-
const [error, setError] = useState<string | null>(null);
29-
const [internalStartDate, setInternalStartDate] = useState<DateTime>(getDefaultStartDate);
30-
31-
const startDateRef = useRef(internalStartDate);
32-
startDateRef.current = internalStartDate;
33-
34-
const fetchUsageStats = useCallback(async (date: DateTime) => {
35-
if (!customerName) {
36-
setUsageStats([]);
37-
setDailyP95(undefined);
38-
setLoading(false);
39-
return;
40-
}
41-
42-
try {
43-
setLoading(true);
44-
setError(null);
45-
const data = await service.getUsageStats(
46-
abortController.current,
47-
customerName,
48-
date.toJSDate()
49-
);
50-
setUsageStats(data.stats);
51-
setDailyP95(data.dailyP95);
52-
} catch (err) {
53-
setError(handleError(err as Error));
54-
} finally {
55-
setLoading(false);
56-
}
57-
}, [service, customerName]);
58-
59-
const setStartDate = useCallback((date: DateTime) => {
60-
setInternalStartDate(date);
61-
fetchUsageStats(date);
62-
}, [fetchUsageStats]);
63-
64-
useEffect(() => {
65-
fetchUsageStats(startDateRef.current);
66-
return () => {
67-
abortController.current.abort();
68-
abortController.current = new AbortController();
69-
};
70-
}, [fetchUsageStats]);
71-
72-
return { usageStats, dailyP95, loading, error, startDate: internalStartDate, setStartDate };
28+
const [startDate, setStartDate] = useState<DateTime>(getDefaultStartDate);
29+
30+
const { data, isFetching, error } = useQuery({
31+
queryKey: ['usage-stats', customerName ?? null, startDate.toMillis()],
32+
queryFn: ({ signal }) => service.getUsageStats(controllerFromSignal(signal), customerName!, startDate.toJSDate()),
33+
enabled: !!customerName,
34+
});
35+
36+
return {
37+
usageStats: data?.stats ?? NO_STATS,
38+
dailyP95: data?.dailyP95,
39+
loading: isFetching,
40+
error: error ? handleError(error as Error) : null,
41+
startDate,
42+
setStartDate,
43+
};
7344
};

webui/src/context/scan-admin/scan-api-actions.ts

Lines changed: 59 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import type { Dispatch, MutableRefObject } from 'react';
1515
import { useCallback } from 'react';
16+
import { useMutation } from '@tanstack/react-query';
1617
import { ScanState, ScanAction } from './scan-types';
1718

1819
// ============================================================================
@@ -30,10 +31,8 @@ export const useConfirmAction = (
3031
dispatch: Dispatch<ScanAction>,
3132
handleErrorRef: MutableRefObject<(error: any) => void>
3233
) => {
33-
return useCallback(async () => {
34-
const abortController = new AbortController();
35-
36-
try {
34+
const { mutate } = useMutation({
35+
mutationFn: async () => {
3736
// Get selected scan IDs
3837
const selectedScanIds: string[] = [];
3938
for (const id in state.quarantinedChecked) {
@@ -43,33 +42,40 @@ export const useConfirmAction = (
4342
}
4443

4544
if (selectedScanIds.length === 0) {
46-
dispatch({ type: 'CLOSE_CONFIRM_DIALOG' });
47-
return;
45+
return { skipped: true };
4846
}
4947

5048
const decision = state.confirmAction === 'allow' ? 'allowed' : 'blocked';
5149

5250
// Backend handles adding enforced threat files to allow/block list automatically
53-
const scanResponse = await service.admin.makeScanDecision(abortController, {
51+
const scanResponse = await service.admin.makeScanDecision({
5452
scanIds: selectedScanIds,
5553
decision,
5654
});
5755

5856
if (scanResponse.error) {
59-
handleErrorRef.current(new Error(scanResponse.error));
60-
return;
57+
throw new Error(scanResponse.error);
6158
}
6259

60+
return { skipped: false };
61+
},
62+
onSuccess: (result) => {
63+
if (result.skipped) {
64+
dispatch({ type: 'CLOSE_CONFIRM_DIALOG' });
65+
return;
66+
}
6367
// Update local state and trigger refresh
6468
dispatch({ type: 'EXECUTE_CONFIRM_ACTION' });
6569
dispatch({ type: 'TRIGGER_REFRESH' });
66-
67-
} catch (err: any) {
68-
if (!abortController.signal.aborted) {
69-
handleErrorRef.current(err);
70-
}
71-
}
72-
}, [service, state.quarantinedChecked, state.confirmAction, dispatch, handleErrorRef]);
70+
},
71+
onError: (err) => {
72+
handleErrorRef.current(err);
73+
},
74+
});
75+
76+
return useCallback(() => {
77+
mutate();
78+
}, [mutate]);
7379
};
7480

7581
// ============================================================================
@@ -85,18 +91,23 @@ export const useRetryFailedScannerJobsAction = (
8591
dispatch: Dispatch<ScanAction>,
8692
handleErrorRef: MutableRefObject<(error: any) => void>
8793
) => {
88-
return useCallback(async (scanId: string): Promise<void> => {
89-
const abortController = new AbortController();
94+
const { mutateAsync } = useMutation({
95+
mutationFn: (scanId: string) => service.admin.retryFailedScannerJobs(scanId),
96+
onSuccess: () => {
97+
dispatch({ type: 'TRIGGER_REFRESH' });
98+
},
99+
onError: (err) => {
100+
handleErrorRef.current(err);
101+
},
102+
});
90103

104+
return useCallback(async (scanId: string): Promise<void> => {
91105
try {
92-
await service.admin.retryFailedScannerJobs(abortController, scanId);
93-
dispatch({ type: 'TRIGGER_REFRESH' });
94-
} catch (err: any) {
95-
if (!abortController.signal.aborted) {
96-
handleErrorRef.current(err);
97-
}
106+
await mutateAsync(scanId);
107+
} catch {
108+
// Error is surfaced via the mutation's onError handler.
98109
}
99-
}, [service, dispatch, handleErrorRef]);
110+
}, [mutateAsync]);
100111
};
101112

102113
// ============================================================================
@@ -113,53 +124,57 @@ export const useFileAction = (
113124
dispatch: Dispatch<ScanAction>,
114125
handleErrorRef: MutableRefObject<(error: any) => void>
115126
) => {
116-
return useCallback(async () => {
117-
const abortController = new AbortController();
118-
119-
try {
127+
const { mutate } = useMutation({
128+
mutationFn: async () => {
120129
if (state.filesChecked.size === 0) {
121-
dispatch({ type: 'CLOSE_FILE_DIALOG' });
122-
return;
130+
return { skipped: true };
123131
}
124132

125133
const selectedFileIds = Array.from(state.filesChecked).map(id => parseInt(id, 10));
126134

127135
if (state.fileActionType === 'delete') {
128-
const response = await service.admin.deleteFileDecisions(abortController, {
136+
const response = await service.admin.deleteFileDecisions({
129137
fileIds: selectedFileIds,
130138
});
131139

132140
if (response.error) {
133-
handleErrorRef.current(new Error(response.error));
134-
return;
141+
throw new Error(response.error);
135142
}
136143
} else {
137144
const decision = state.fileActionType === 'allow' ? 'allowed' : 'blocked';
138145

139146
const selectedFiles = state.files.filter(file => state.filesChecked.has(file.id));
140147
const fileHashes = selectedFiles.map(file => file.fileHash);
141148

142-
const response = await service.admin.makeFileDecision(abortController, {
149+
const response = await service.admin.makeFileDecision({
143150
fileHashes,
144151
decision,
145152
});
146153

147154
if (response.error) {
148-
handleErrorRef.current(new Error(response.error));
149-
return;
155+
throw new Error(response.error);
150156
}
151157
}
152158

159+
return { skipped: false };
160+
},
161+
onSuccess: (result) => {
162+
if (result.skipped) {
163+
dispatch({ type: 'CLOSE_FILE_DIALOG' });
164+
return;
165+
}
153166
// Update local state and trigger refresh
154167
dispatch({ type: 'SET_FILES_CHECKED', payload: new Set() });
155168
dispatch({ type: 'CLOSE_FILE_DIALOG' });
156169
dispatch({ type: 'RESET_PAGE' }); // Go back to first page after action
157170
dispatch({ type: 'TRIGGER_REFRESH' });
158-
159-
} catch (err: any) {
160-
if (!abortController.signal.aborted) {
161-
handleErrorRef.current(err);
162-
}
163-
}
164-
}, [service, state.filesChecked, state.fileActionType, state.files, dispatch, handleErrorRef]);
171+
},
172+
onError: (err) => {
173+
handleErrorRef.current(err);
174+
},
175+
});
176+
177+
return useCallback(() => {
178+
mutate();
179+
}, [mutate]);
165180
};

0 commit comments

Comments
 (0)