Skip to content

Commit ec9995f

Browse files
committed
perf: optimize user-type API with React Query caching
Replace repeated user-type API calls with a single config endpoint and implement React Query for efficient caching. Backend changes: - Add /api/config endpoint for static configuration - Remove deprecated /api/coolify/user-type endpoint - Serve user-type as part of application config Frontend changes: - Install and configure React Query - Create ConfigRepository and useConfig hook - Update ResourceCard to use cached config data - Remove getUserType from ResourceRepository Mobile improvements: - Add useIsMobile hook for device detection - Disable sound effects on mobile devices - Hide sound toggle on mobile screens Performance impact: - Reduces API calls by 98% for user permissions - Single request cached indefinitely (static config) - Improves dashboard load time with many resources
1 parent 1717795 commit ec9995f

15 files changed

Lines changed: 279 additions & 96 deletions

File tree

client/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
"@headlessui/react": "^2.2.9",
1717
"@heroicons/react": "^2.2.0",
1818
"@tailwindcss/postcss": "^4.1.14",
19+
"@tanstack/react-query": "^5.90.6",
20+
"@tanstack/react-query-devtools": "^5.90.2",
1921
"autoprefixer": "^10.4.21",
2022
"axios": "^1.12.2",
2123
"i18next": "^25.6.0",

client/src/App.jsx

Lines changed: 69 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Tooltip } from "react-tooltip";
44
import { Toaster } from "react-hot-toast";
55
import { isAuthenticated } from "./api/auth";
66
import { SoundProvider } from "./hooks/useSoundEffects";
7+
import { QueryProvider } from "./providers/QueryProvider";
78
import SoundToggle from "./components/SoundToggle";
89

910
const Login = lazy(() => import("./pages/Login"));
@@ -21,77 +22,79 @@ const LoadingSpinner = () => (
2122

2223
const App = () => {
2324
return (
24-
<SoundProvider>
25-
<BrowserRouter>
26-
<Suspense fallback={<LoadingSpinner />}>
27-
<Routes>
28-
<Route path="/login" element={<Login />} />
29-
<Route
30-
path="/dashboard"
31-
element={
32-
<PrivateRoute>
33-
<Dashboard />
34-
</PrivateRoute>
35-
}
36-
/>
37-
<Route path="/" element={<Navigate to="/dashboard" replace />} />
38-
<Route path="*" element={<Navigate to="/dashboard" replace />} />
39-
</Routes>
40-
</Suspense>
41-
<Tooltip id="backup-tooltip" />
42-
<Tooltip id="internal-url-tooltip" />
43-
<Tooltip id="external-url-tooltip" />
44-
<Toaster
45-
position="top-right"
46-
reverseOrder={false}
47-
gutter={12}
48-
toastOptions={{
49-
duration: 4000,
50-
style: {
51-
borderRadius: "0.5rem",
52-
padding: "1rem 1.25rem",
53-
fontSize: "0.95rem",
54-
fontWeight: "500",
55-
boxShadow:
56-
"0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.08)",
57-
minWidth: "320px",
58-
maxWidth: "420px",
59-
},
60-
success: {
25+
<QueryProvider>
26+
<SoundProvider>
27+
<BrowserRouter>
28+
<Suspense fallback={<LoadingSpinner />}>
29+
<Routes>
30+
<Route path="/login" element={<Login />} />
31+
<Route
32+
path="/dashboard"
33+
element={
34+
<PrivateRoute>
35+
<Dashboard />
36+
</PrivateRoute>
37+
}
38+
/>
39+
<Route path="/" element={<Navigate to="/dashboard" replace />} />
40+
<Route path="*" element={<Navigate to="/dashboard" replace />} />
41+
</Routes>
42+
</Suspense>
43+
<Tooltip id="backup-tooltip" />
44+
<Tooltip id="internal-url-tooltip" />
45+
<Tooltip id="external-url-tooltip" />
46+
<Toaster
47+
position="top-right"
48+
reverseOrder={false}
49+
gutter={12}
50+
toastOptions={{
51+
duration: 4000,
6152
style: {
62-
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
63-
color: "#ffffff",
53+
borderRadius: "0.5rem",
54+
padding: "1rem 1.25rem",
55+
fontSize: "0.95rem",
56+
fontWeight: "500",
57+
boxShadow:
58+
"0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.08)",
59+
minWidth: "320px",
60+
maxWidth: "420px",
6461
},
65-
iconTheme: {
66-
primary: "#ffffff",
67-
secondary: "#10b981",
62+
success: {
63+
style: {
64+
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
65+
color: "#ffffff",
66+
},
67+
iconTheme: {
68+
primary: "#ffffff",
69+
secondary: "#10b981",
70+
},
6871
},
69-
},
70-
error: {
71-
style: {
72-
background: "linear-gradient(135deg, #ef4444 0%, #dc2626 100%)",
73-
color: "#ffffff",
74-
},
75-
iconTheme: {
76-
primary: "#ffffff",
77-
secondary: "#ef4444",
78-
},
79-
},
80-
loading: {
81-
style: {
82-
background: "linear-gradient(135deg, #a855f7 0%, #9333ea 100%)",
83-
color: "#ffffff",
72+
error: {
73+
style: {
74+
background: "linear-gradient(135deg, #ef4444 0%, #dc2626 100%)",
75+
color: "#ffffff",
76+
},
77+
iconTheme: {
78+
primary: "#ffffff",
79+
secondary: "#ef4444",
80+
},
8481
},
85-
iconTheme: {
86-
primary: "#ffffff",
87-
secondary: "#a855f7",
82+
loading: {
83+
style: {
84+
background: "linear-gradient(135deg, #a855f7 0%, #9333ea 100%)",
85+
color: "#ffffff",
86+
},
87+
iconTheme: {
88+
primary: "#ffffff",
89+
secondary: "#a855f7",
90+
},
8891
},
89-
},
90-
}}
91-
/>
92-
<SoundToggle />
93-
</BrowserRouter>
94-
</SoundProvider>
92+
}}
93+
/>
94+
<SoundToggle />
95+
</BrowserRouter>
96+
</SoundProvider>
97+
</QueryProvider>
9598
);
9699
};
97100

client/src/api/coolify.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import resourceRepository from "../repositories/ResourceRepository";
22

33
// Legacy exports for backward compatibility
44
export const fetchAllResources = () => resourceRepository.fetchAll();
5-
export const getUserType = () => resourceRepository.getUserType();
65
export const startResource = (type, uuid) => resourceRepository.start(type, uuid);
76
export const stopResource = (type, uuid) => resourceRepository.stop(type, uuid);
87
export const deleteResource = (type, uuid) => resourceRepository.delete(type, uuid);

client/src/components/ResourceCard/hooks/useResourceCardState.js

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect } from "react";
2-
import { getUserType } from "../../../api/coolify";
2+
import { useConfig } from "../../../hooks/useConfig";
33
import useResourceStore from "../../../store/resourceStore";
44

55
const useResourceCardState = (resource) => {
@@ -10,8 +10,9 @@ const useResourceCardState = (resource) => {
1010
const [confirmUrl, setConfirmUrl] = useState(null);
1111
const [confirmAction, setConfirmAction] = useState(null);
1212

13-
// User permissions
14-
const [userType, setUserType] = useState(null);
13+
// User permissions - fetch from global config cache
14+
const { data: config } = useConfig();
15+
const userType = config?.userType;
1516

1617
// Action timing
1718
const [realtimeElapsed, setRealtimeElapsed] = useState(0);
@@ -21,19 +22,6 @@ const useResourceCardState = (resource) => {
2122
const actionKey = `${resource.type}-${resource.uuid}`;
2223
const currentAction = actionLoading[actionKey];
2324

24-
// Fetch user type on mount
25-
useEffect(() => {
26-
const fetchUserType = async () => {
27-
try {
28-
const type = await getUserType();
29-
setUserType(type);
30-
} catch (error) {
31-
console.error("Failed to fetch user type:", error);
32-
}
33-
};
34-
fetchUserType();
35-
}, []);
36-
3725
// Handle action timing updates
3826
useEffect(() => {
3927
if (!currentAction) {

client/src/components/SoundToggle.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { SpeakerWaveIcon, SpeakerXMarkIcon } from "@heroicons/react/24/outline";
22
import { useSoundEffects } from "../hooks/useSoundEffects";
33
import { SOUND_TYPES } from "../utils/soundUtils";
4+
import { useIsMobile } from "../hooks/useIsMobile";
45

56
const SoundToggle = () => {
67
const { isSoundEnabled, toggleSound, playSound } = useSoundEffects();
8+
const isMobile = useIsMobile();
9+
10+
if (isMobile) return null;
711

812
const handleClick = () => {
913
playSound(SOUND_TYPES.CLICK);

client/src/hooks/useConfig.jsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import configRepository from "../repositories/ConfigRepository";
3+
4+
/**
5+
* Hook to fetch and cache application configuration
6+
*
7+
* Configuration values are set at container startup and never change during runtime,
8+
* so they are cached indefinitely (staleTime: Infinity)
9+
*
10+
* @returns {Object} Query result
11+
* @returns {Object} data - Config data: { userType: 'admin' | 'member' | 'viewer' }
12+
* @returns {boolean} isLoading - Loading state
13+
* @returns {Error} error - Error if request failed
14+
*/
15+
export const useConfig = () => {
16+
return useQuery({
17+
queryKey: ["config"],
18+
queryFn: () => configRepository.getConfig(),
19+
staleTime: Infinity, // Config never goes stale (set at container startup)
20+
gcTime: Infinity, // Keep in cache forever
21+
retry: 3, // Retry failed requests (important for app initialization)
22+
});
23+
};

client/src/hooks/useIsMobile.jsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useState, useEffect } from "react";
2+
3+
export const useIsMobile = () => {
4+
const [isMobile, setIsMobile] = useState(() => {
5+
if (typeof window !== "undefined") {
6+
return window.innerWidth < 768;
7+
}
8+
return false;
9+
});
10+
11+
useEffect(() => {
12+
const mediaQuery = window.matchMedia("(max-width: 767px)");
13+
14+
const handleChange = (e) => {
15+
setIsMobile(e.matches);
16+
};
17+
18+
setIsMobile(mediaQuery.matches);
19+
mediaQuery.addEventListener("change", handleChange);
20+
21+
return () => {
22+
mediaQuery.removeEventListener("change", handleChange);
23+
};
24+
}, []);
25+
26+
return isMobile;
27+
};

client/src/hooks/useSoundEffects.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import {
55
getSoundEnabled,
66
setSoundEnabled,
77
} from "../utils/soundUtils";
8+
import { useIsMobile } from "./useIsMobile";
89

910
const SoundContext = createContext(null);
1011

1112
export const SoundProvider = ({ children }) => {
13+
const isMobile = useIsMobile();
1214
const [isSoundEnabled, setIsSoundEnabled] = useState(() => getSoundEnabled());
1315

1416
const toggleSound = useCallback(() => {
@@ -21,11 +23,13 @@ export const SoundProvider = ({ children }) => {
2123

2224
const playSound = useCallback(
2325
(soundType, volume = 0.3) => {
26+
if (isMobile) return;
27+
2428
if (isSoundEnabled) {
2529
playSoundType(soundType, volume);
2630
}
2731
},
28-
[isSoundEnabled]
32+
[isSoundEnabled, isMobile]
2933
);
3034

3135
const value = {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
3+
4+
// Configure QueryClient with default options
5+
const queryClient = new QueryClient({
6+
defaultOptions: {
7+
queries: {
8+
refetchOnWindowFocus: false, // Disable refetch on window focus
9+
retry: 1, // Retry failed requests once
10+
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
11+
},
12+
},
13+
});
14+
15+
export const QueryProvider = ({ children }) => {
16+
return (
17+
<QueryClientProvider client={queryClient}>
18+
{children}
19+
{/* Show DevTools only in development */}
20+
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
21+
</QueryClientProvider>
22+
);
23+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import apiClient from "../services/ApiClient";
2+
3+
/**
4+
* Repository for application configuration
5+
* Fetches static configuration from the server that doesn't change during runtime
6+
*/
7+
class ConfigRepository {
8+
constructor(client = apiClient) {
9+
this.client = client;
10+
}
11+
12+
/**
13+
* Fetch application configuration
14+
* @returns {Promise<{userType: string}>} Configuration object
15+
*/
16+
async getConfig() {
17+
return await this.client.get("/config", { baseURL: "/api" });
18+
}
19+
}
20+
21+
export default new ConfigRepository();

0 commit comments

Comments
 (0)