From 996c8074eb841f819c0cb6398cfef9b29aaf8a22 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 4 May 2026 12:33:17 +0000 Subject: [PATCH 1/9] Optimize loading --- src/frontend/src/functions/auth.tsx | 16 ++++++++++++---- src/frontend/src/pages/Auth/Login.tsx | 8 +++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index 6f541f049c0d..c56842ba5096 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -19,6 +19,14 @@ import { fetchGlobalStates } from '../states/states'; import { showLoginNotification } from './notifications'; import { generateUrl } from './urls'; +function refreshGlobalStatesInBackground() { + // Do not block navigation on non-critical bootstrap data. + // Pages can render quickly while global state warms in the background. + fetchGlobalStates().catch((error) => { + console.error('ERR: Failed to refresh global states', error); + }); +} + export function followRedirect(navigate: NavigateFunction, redirect: any) { let url = redirect?.redirectUrl ?? '/home'; @@ -156,8 +164,8 @@ export async function doBasicLogin( // we are successfully logged in - gather required states for app if (loginDone) { await fetchUserState(); - await fetchGlobalStates(); observeProfile(); + refreshGlobalStatesInBackground(); } else if (!success) { clearUserState(); } @@ -440,9 +448,8 @@ export const checkLoginState = async ( MfaSetupOk(navigate).then(async (isOk) => { if (isOk) { observeProfile(); - await fetchGlobalStates(); - followRedirect(navigate, redirect); + refreshGlobalStatesInBackground(); } }); }; @@ -490,11 +497,12 @@ function handleSuccessFullAuth( if (isOk) { await fetchUserState(); observeProfile(); - await fetchGlobalStates(); if (location !== undefined) { followRedirect(navigate, location?.state); } + + refreshGlobalStatesInBackground(); } }); } diff --git a/src/frontend/src/pages/Auth/Login.tsx b/src/frontend/src/pages/Auth/Login.tsx index 9e9d0098ec36..f1b672f25bd3 100644 --- a/src/frontend/src/pages/Auth/Login.tsx +++ b/src/frontend/src/pages/Auth/Login.tsx @@ -21,6 +21,7 @@ import { } from '../../functions/auth'; import { useLocalState } from '../../states/LocalState'; import { useServerApiState } from '../../states/ServerApiState'; +import { useUserState } from '../../states/UserState'; import { Wrapper } from './Layout'; export default function Login() { @@ -45,6 +46,9 @@ export default function Login() { state.registration_enabled ]) ); + const [loginChecked] = useUserState( + useShallow((state) => [state.login_checked]) + ); const any_reg_enabled = registration_enabled() || sso_registration() || false; const LoginMessage = useMemo(() => { @@ -79,7 +83,9 @@ export default function Login() { ChangeHost(defaultHostKey); } - checkLoginState(navigate, location?.state, true); + if (!loginChecked) { + checkLoginState(navigate, location?.state, true); + } // check if we got login params (login and password) if (searchParams.has('login') && searchParams.has('password')) { From 6ed1ec2d91064343e3e1d1b643bd1bf19c7fcba1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 4 May 2026 13:14:13 +0000 Subject: [PATCH 2/9] Add view caching for static endpoints - version view - license view --- src/backend/InvenTree/InvenTree/api.py | 182 ++++++++++++++----------- 1 file changed, 99 insertions(+), 83 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 87edf69474e3..5ea6cca2857d 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -6,10 +6,13 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.db import transaction from django.http import JsonResponse from django.urls import path, reverse +from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ +from django.views.decorators.cache import cache_page from django.views.generic.base import RedirectView import structlog @@ -42,45 +45,6 @@ logger = structlog.get_logger('inventree') -def read_license_file(path: Path) -> list: - """Extract license information from the provided file. - - Arguments: - path: Path to the license file - - Returns: A list of items containing the license information - """ - # Check if the file exists - if not path.exists(): - logger.error("License file not found at '%s'", path) - return [] - - try: - data = json.loads(path.read_text(encoding='utf-8')) - except Exception as e: - logger.exception("Failed to parse license file '%s': %s", path, e) - return [] - - output = [] - names = set() - - # Ensure we do not have any duplicate 'name' values in the list - for entry in data: - name = None - for key in entry: - if key.lower() == 'name': - name = entry[key] - break - - if name is None or name in names: - continue - - names.add(name) - output.append({key.lower(): value for key, value in entry.items()}) - - return sorted(output, key=lambda x: x.get('name', '').lower()) - - class LicenseViewSerializer(serializers.Serializer): """Serializer for license information.""" @@ -90,11 +54,50 @@ class LicenseViewSerializer(serializers.Serializer): ) +@method_decorator(cache_page(60 * 15), name='dispatch') class LicenseView(APIView): """Simple JSON endpoint for InvenTree license information.""" permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope] + def read_license_file(self, path: Path) -> list: + """Extract license information from the provided file. + + Arguments: + path: Path to the license file + + Returns: A list of items containing the license information + """ + # Check if the file exists + if not path.exists(): + logger.error("License file not found at '%s'", path) + return [] + + try: + data = json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.exception("Failed to parse license file '%s': %s", path, e) + return [] + + output = [] + names = set() + + # Ensure we do not have any duplicate 'name' values in the list + for entry in data: + name = None + for key in entry: + if key.lower() == 'name': + name = entry[key] + break + + if name is None or name in names: + continue + + names.add(name) + output.append({key.lower(): value for key, value in entry.items()}) + + return sorted(output, key=lambda x: x.get('name', '').lower()) + @extend_schema(responses={200: OpenApiResponse(response=LicenseViewSerializer)}) def get(self, request, *args, **kwargs): """Return information about the InvenTree server.""" @@ -102,9 +105,10 @@ def get(self, request, *args, **kwargs): frontend = InvenTree.config.get_base_dir().joinpath( 'web/static/web/.vite/dependencies.json' ) + return JsonResponse({ - 'backend': read_license_file(backend), - 'frontend': read_license_file(frontend), + 'backend': self.read_license_file(backend), + 'frontend': self.read_license_file(frontend), }) @@ -136,6 +140,7 @@ class LinkSerializer(serializers.Serializer): links = LinkSerializer() +@method_decorator(cache_page(60 * 15), name='dispatch') class VersionView(APIView): """Simple JSON endpoint for InvenTree version information.""" @@ -290,48 +295,59 @@ def get(self, request, *args, **kwargs): # Might be Token auth - check if so is_staff = self.check_auth_header(request) - data = { - 'server': 'InvenTree', - 'id': InvenTree.version.inventree_identifier(), - 'version': InvenTree.version.inventreeVersion(), - 'instance': InvenTree.version.inventreeInstanceName(), - 'apiVersion': InvenTree.version.inventreeApiVersion(), - 'worker_running': is_worker_running(), - 'worker_count': settings.Q_CLUSTER['workers'], - 'worker_pending_tasks': self.worker_pending_tasks(), - 'plugins_enabled': settings.PLUGINS_ENABLED, - 'plugins_install_disabled': settings.PLUGINS_INSTALL_DISABLED, - 'email_configured': is_email_configured(), - 'debug_mode': settings.DEBUG, - 'docker_mode': settings.DOCKER, - 'default_locale': settings.LANGUAGE_CODE, - 'customize': { - 'logo': helpers.getLogoImage(), - 'splash': helpers.getSplashScreen(), - 'login_message': helpers.getCustomOption('login_message'), - 'navbar_message': helpers.getCustomOption('navbar_message'), - 'disable_theme_storage': str2bool( - helpers.getCustomOption('disable_theme_storage') - ), - }, - 'active_plugins': plugins_info(), - # Following fields are only available to staff users - 'system_health': check_system_health() if is_staff else None, - 'database': InvenTree.version.inventreeDatabase() if is_staff else None, - 'platform': InvenTree.version.inventreePlatform() if is_staff else None, - 'installer': InvenTree.config.inventreeInstaller() if is_staff else None, - 'target': InvenTree.version.inventreeTarget() if is_staff else None, - 'django_admin': settings.INVENTREE_ADMIN_URL - if (is_staff and settings.INVENTREE_ADMIN_ENABLED) - else None, - 'settings': { - 'sso_registration': registration_enabled('LOGIN_ENABLE_SSO_REG'), - 'registration_enabled': registration_enabled('LOGIN_ENABLE_REG'), - 'password_forgotten_enabled': get_global_setting( - 'LOGIN_ENABLE_PWD_FORGOT' - ), - }, - } + # Cache the results of this view, per user + cache_key = f'api-info-{request.user.pk if request.user.is_authenticated else "A"}-{"Y" if is_staff else "N"}' + + data = cache.get(cache_key, None) + + if not data: + data = { + 'server': 'InvenTree', + 'id': InvenTree.version.inventree_identifier(), + 'version': InvenTree.version.inventreeVersion(), + 'instance': InvenTree.version.inventreeInstanceName(), + 'apiVersion': InvenTree.version.inventreeApiVersion(), + 'worker_running': is_worker_running(), + 'worker_count': settings.Q_CLUSTER['workers'], + 'worker_pending_tasks': self.worker_pending_tasks(), + 'plugins_enabled': settings.PLUGINS_ENABLED, + 'plugins_install_disabled': settings.PLUGINS_INSTALL_DISABLED, + 'email_configured': is_email_configured(), + 'debug_mode': settings.DEBUG, + 'docker_mode': settings.DOCKER, + 'default_locale': settings.LANGUAGE_CODE, + 'customize': { + 'logo': helpers.getLogoImage(), + 'splash': helpers.getSplashScreen(), + 'login_message': helpers.getCustomOption('login_message'), + 'navbar_message': helpers.getCustomOption('navbar_message'), + 'disable_theme_storage': str2bool( + helpers.getCustomOption('disable_theme_storage') + ), + }, + 'active_plugins': plugins_info(), + # Following fields are only available to staff users + 'system_health': check_system_health() if is_staff else None, + 'database': InvenTree.version.inventreeDatabase() if is_staff else None, + 'platform': InvenTree.version.inventreePlatform() if is_staff else None, + 'installer': InvenTree.config.inventreeInstaller() + if is_staff + else None, + 'target': InvenTree.version.inventreeTarget() if is_staff else None, + 'django_admin': settings.INVENTREE_ADMIN_URL + if (is_staff and settings.INVENTREE_ADMIN_ENABLED) + else None, + 'settings': { + 'sso_registration': registration_enabled('LOGIN_ENABLE_SSO_REG'), + 'registration_enabled': registration_enabled('LOGIN_ENABLE_REG'), + 'password_forgotten_enabled': get_global_setting( + 'LOGIN_ENABLE_PWD_FORGOT' + ), + }, + } + + # Cache for one minute - this will speed up repeated calls to this endpoint + cache.set(cache_key, data, 60) return JsonResponse(data) From 549a2c48772cc6c591def98daa7793998aacf5fe Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 4 May 2026 13:17:29 +0000 Subject: [PATCH 3/9] Cache the AllStatusViews endpoint --- src/backend/InvenTree/generic/states/api.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/backend/InvenTree/generic/states/api.py b/src/backend/InvenTree/generic/states/api.py index 4047c69161b5..45272954281e 100644 --- a/src/backend/InvenTree/generic/states/api.py +++ b/src/backend/InvenTree/generic/states/api.py @@ -3,6 +3,8 @@ import inspect from django.urls import include, path +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiResponse, extend_schema @@ -104,6 +106,8 @@ def get(self, request, *args, **kwargs): return Response(serializer.data) +# Cache the AllStatusViews response - these values are not expected to change frequently +@method_decorator(cache_page(60), name='dispatch') class AllStatusViews(StatusView): """Endpoint for listing all defined status models.""" From a96a0453307a96754270ad813b56307cf4b7793d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 4 May 2026 13:33:09 +0000 Subject: [PATCH 4/9] Revert changes to auth.tsx --- src/frontend/src/functions/auth.tsx | 14 +++----------- src/frontend/src/states/states.tsx | 7 +++++-- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index c56842ba5096..654aa3999006 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -19,14 +19,6 @@ import { fetchGlobalStates } from '../states/states'; import { showLoginNotification } from './notifications'; import { generateUrl } from './urls'; -function refreshGlobalStatesInBackground() { - // Do not block navigation on non-critical bootstrap data. - // Pages can render quickly while global state warms in the background. - fetchGlobalStates().catch((error) => { - console.error('ERR: Failed to refresh global states', error); - }); -} - export function followRedirect(navigate: NavigateFunction, redirect: any) { let url = redirect?.redirectUrl ?? '/home'; @@ -165,7 +157,7 @@ export async function doBasicLogin( if (loginDone) { await fetchUserState(); observeProfile(); - refreshGlobalStatesInBackground(); + await fetchGlobalStates(); } else if (!success) { clearUserState(); } @@ -449,7 +441,7 @@ export const checkLoginState = async ( if (isOk) { observeProfile(); followRedirect(navigate, redirect); - refreshGlobalStatesInBackground(); + await fetchGlobalStates(); } }); }; @@ -502,7 +494,7 @@ function handleSuccessFullAuth( followRedirect(navigate, location?.state); } - refreshGlobalStatesInBackground(); + await fetchGlobalStates(); } }); } diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index 4e9a1cfe338a..ddeec1d854b5 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -56,10 +56,13 @@ export async function fetchGlobalStates() { const traceId = setTraceId(); await Promise.all([ useServerApiState.getState().fetchServerApiState(), - useUserSettingsState.getState().fetchSettings(), - useGlobalSettingsState.getState().fetchSettings(), useGlobalStatusState.getState().fetchStatus(), useIconState.getState().fetchIcons() ]); + removeTraceId(traceId); + + // Load settings in the background + useUserSettingsState.getState().fetchSettings(); + useGlobalSettingsState.getState().fetchSettings(); } From 0696cb8c0841d2e83431a4f3b64044a0f91f8863 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 4 May 2026 13:33:19 +0000 Subject: [PATCH 5/9] Cache the main web index --- src/backend/InvenTree/web/urls.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/web/urls.py b/src/backend/InvenTree/web/urls.py index b55b4937dd51..a804d6c375d0 100644 --- a/src/backend/InvenTree/web/urls.py +++ b/src/backend/InvenTree/web/urls.py @@ -2,10 +2,13 @@ from django.conf import settings from django.urls import include, path, re_path +from django.views.decorators.cache import cache_page from django.views.decorators.csrf import ensure_csrf_cookie from django.views.generic import RedirectView, TemplateView -spa_view = ensure_csrf_cookie(TemplateView.as_view(template_name='web/index.html')) +spa_view = ensure_csrf_cookie( + cache_page(60 * 5)(TemplateView.as_view(template_name='web/index.html')) +) def cui_compatibility_urls(base: str) -> list: From 5aad15ff9cc2fc95610cf92448b1ad709f59ac2d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 4 May 2026 13:41:10 +0000 Subject: [PATCH 6/9] Remove caching --- src/backend/InvenTree/web/urls.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/backend/InvenTree/web/urls.py b/src/backend/InvenTree/web/urls.py index a804d6c375d0..b55b4937dd51 100644 --- a/src/backend/InvenTree/web/urls.py +++ b/src/backend/InvenTree/web/urls.py @@ -2,13 +2,10 @@ from django.conf import settings from django.urls import include, path, re_path -from django.views.decorators.cache import cache_page from django.views.decorators.csrf import ensure_csrf_cookie from django.views.generic import RedirectView, TemplateView -spa_view = ensure_csrf_cookie( - cache_page(60 * 5)(TemplateView.as_view(template_name='web/index.html')) -) +spa_view = ensure_csrf_cookie(TemplateView.as_view(template_name='web/index.html')) def cui_compatibility_urls(base: str) -> list: From ad1b6fd4357f0aca14186b2731c594ad5e0fbead Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 4 May 2026 13:41:24 +0000 Subject: [PATCH 7/9] Change rollup settings to parallel load icons --- src/frontend/vite.config.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index 627a1ade6e1b..a96c79d4c13d 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -63,7 +63,19 @@ export default defineConfig(({ command, mode }) => { build: { manifest: true, outDir: OUTPUT_DIR, - sourcemap: true + sourcemap: true, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('@tabler/icons-react')) { + return 'tabler-icons'; + } + if (id.includes('@mantine')) { + return 'mantine'; + } + } + } + } }, resolve: { alias: { From 7ff22f8515704b6470dc840fabd69cdb7578f4ef Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 4 May 2026 13:59:24 +0000 Subject: [PATCH 8/9] Revert some changes --- src/frontend/src/functions/auth.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index 654aa3999006..5deb8c329b7c 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -440,8 +440,8 @@ export const checkLoginState = async ( MfaSetupOk(navigate).then(async (isOk) => { if (isOk) { observeProfile(); - followRedirect(navigate, redirect); await fetchGlobalStates(); + followRedirect(navigate, redirect); } }); }; @@ -489,12 +489,11 @@ function handleSuccessFullAuth( if (isOk) { await fetchUserState(); observeProfile(); + await fetchGlobalStates(); if (location !== undefined) { followRedirect(navigate, location?.state); } - - await fetchGlobalStates(); } }); } From 580629c18801ae695f3d6f8d8e21ea3cbfdf6858 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 4 May 2026 14:10:18 +0000 Subject: [PATCH 9/9] Remove cache from LicenseView --- src/backend/InvenTree/InvenTree/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 5ea6cca2857d..d125e232cc8a 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -54,7 +54,6 @@ class LicenseViewSerializer(serializers.Serializer): ) -@method_decorator(cache_page(60 * 15), name='dispatch') class LicenseView(APIView): """Simple JSON endpoint for InvenTree license information."""