Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
"storybook": "storybook dev -p 6006",
"build-typedoc": "typedoc",
"build-storybook": "storybook build -o ../docs/development/storybook",
"i18n": "i18next 'src/**/*.{ts,tsx}' -c ./src/i18n/i18next-parser.config.js",
"i18n": "i18next 'src/**/*.{ts,tsx}' '!src/**/*.test.{ts,tsx}' -c ./src/i18n/i18next-parser.config.js",
"tsc": "tsgo",
"make-version": "node ./make-env.js",
"star": "cross-env REACT_APP_HEADLAMP_BACKEND_TOKEN=headlamp rsbuild dev",
Expand Down
101 changes: 101 additions & 0 deletions frontend/src/plugin/pluginI18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright 2025 The Kubernetes Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { changePluginLanguage, initializePluginI18n, useTranslation } from './pluginI18n';

// Mock react-i18next so the hook can read a "current language" without a provider.
vi.mock('react-i18next', () => ({
useTranslation: () => ({ i18n: { language: 'en' } }),
}));

function localeUrls(fetchSpy: ReturnType<typeof vi.fn>): string[] {
return fetchSpy.mock.calls
.map(args => String(args[0]))
.filter(url => url.includes('/locales/') && url.includes('translation.json'));
}

describe('pluginI18n', () => {
let fetchSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
// Default: every translation file request 404s, mirroring a plugin (e.g. the
// Prometheus plugin in #4854) whose locale files are not present.
fetchSpy = vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
vi.stubGlobal('fetch', fetchSpy);
});
Comment on lines +17 to +40

afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

it('does not fetch translation files for a plugin that does not declare i18n', async () => {
// Plugin with no `headlamp.i18n` in package.json.
await initializePluginI18n(
'no-i18n-plugin',
{ name: 'no-i18n-plugin' },
'/plugins/no-i18n-plugin'
);

const { result } = renderHook(() => useTranslation('no-i18n-plugin'));
await waitFor(() => expect(result.current.ready).toBe(true));

// An undeclared plugin has no translation files, so it must not probe for any.
expect(localeUrls(fetchSpy)).toHaveLength(0);
// With no translations, t() falls back to returning the original key.
expect(result.current.t('Hello')).toBe('Hello');
});

it('fetches only the active language, not every declared locale (#4854)', async () => {
// Mirrors the Prometheus plugin: declares many locales, user is viewing 'en'.
await initializePluginI18n(
'many-locales-plugin',
{
name: 'many-locales-plugin',
headlamp: { i18n: ['en', 'de', 'es', 'fr', 'ja', 'ko', 'zh'] },
},
'/plugins/many-locales-plugin'
);

const urls = localeUrls(fetchSpy);
// Only the active language is fetched up front - not the whole declared list,
// which is what produced the 404 flood in #4854.
expect(urls).toEqual([
expect.stringContaining('/plugins/many-locales-plugin/locales/en/translation.json'),
]);
expect(urls.some(url => url.includes('/locales/ja/'))).toBe(false);
});

it('lazily fetches a locale only when the language switches to it', async () => {
await initializePluginI18n(
'lazy-plugin',
{ name: 'lazy-plugin', headlamp: { i18n: ['en', 'de'] } },
'/plugins/lazy-plugin'
);
// 'de' was not fetched during init (user is on 'en').
expect(localeUrls(fetchSpy).some(url => url.includes('/locales/de/'))).toBe(false);

fetchSpy.mockResolvedValue({ ok: true, status: 200, json: async () => ({ Hello: 'Hallo' }) });
await changePluginLanguage('de');

// Switching to 'de' triggers an on-demand fetch for it.
expect(fetchSpy).toHaveBeenCalledWith(
expect.stringContaining('/plugins/lazy-plugin/locales/de/translation.json')
);
});
});
88 changes: 74 additions & 14 deletions frontend/src/plugin/pluginI18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@ async function loadPluginTranslations(
return {};
}

/**
* Lazily load a single locale's translations into an existing plugin instance.
* Used when the active language changes, so a locale is only fetched the first
* time it is actually needed instead of all locales being fetched up front
* (see #4854).
*/
async function ensurePluginLocaleLoaded(
instance: i18n,
pluginName: string,
pluginPath: string,
locale: string
): Promise<void> {
if (instance.hasResourceBundle(locale, pluginName)) {
return;
}
const translations = await loadPluginTranslations(pluginPath, locale);
if (Object.keys(translations).length > 0) {
instance.addResourceBundle(locale, pluginName, translations);
}
}

/**
* Create or get an i18next instance for a plugin
* Uses hard defaults: namespace = plugin name, loads from plugin path
Expand All @@ -87,12 +108,23 @@ async function getPluginI18nInstance(

const instance = createInstance();

// Use supported locales from package.json or fall back to common locales
const locales = supportedLocales || ['en', 'es', 'fr', 'de', 'pt', 'it', 'zh', 'ko', 'ja'];
// Only consider the locales a plugin explicitly declares via `headlamp.i18n`
// in package.json. Without a declaration there are no translation files to
// load, so probing for them would only generate 404s (see #4854).
const locales = supportedLocales ?? [];

// Eagerly load only the locale the user is actually viewing (plus the English
// fallback). Previously every declared locale was fetched up front, so a plugin
// declaring many locales whose files are missing from the image produced a 404
// per locale on every load - enough to trip rate limiters like CrowdSec (#4854).
// Other locales are loaded lazily when the language changes.
const currentLanguage = i18next.language || 'en';
const initialLocales = [...new Set([currentLanguage, 'en'])].filter(locale =>
locales.includes(locale)
);
const resources: Record<string, Record<string, Record<string, string>>> = {};

// Load translations for each locale that exists
for (const locale of locales) {
for (const locale of initialLocales) {
const translations = await loadPluginTranslations(pluginPath, locale);
if (Object.keys(translations).length > 0) {
resources[locale] = {
Expand Down Expand Up @@ -233,8 +265,17 @@ export function useTranslation(pluginNameParam?: string): UseTranslationResult {
return;
}

// Initialize plugin i18n instance
// Only initialize an i18n instance for plugins that explicitly declare
// their supported locales via `headlamp.i18n` in package.json. Plugins
// without a declaration have no translation files, so probing for them
// produces a flood of 404s (see #4854). For those, leave `instance` null
// so `t()` falls back to returning the original string keys.
const supportedLocales = pluginSupportedLocales[currentPluginName];
if (!supportedLocales?.length) {
setReady(true);
return;
}

const pluginInstance = await getPluginI18nInstance(
currentPluginName,
pluginPath,
Expand All @@ -251,12 +292,22 @@ export function useTranslation(pluginNameParam?: string): UseTranslationResult {
initializeTranslations();
}, [pluginName]);

// Sync language changes from main i18n
// Sync language changes from main i18n, loading the target locale on demand.
useEffect(() => {
if (instance && mainI18n?.language !== instance.language) {
instance.changeLanguage(mainI18n.language);
const targetLanguage = mainI18n?.language;
if (!instance || !pluginName || !targetLanguage || targetLanguage === instance.language) {
return;
}
Comment on lines 296 to 300
}, [mainI18n?.language, instance]);

const pluginPath = pluginPaths[pluginName];
const supportedLocales = pluginSupportedLocales[pluginName];
(async () => {
if (pluginPath && supportedLocales?.includes(targetLanguage)) {
await ensurePluginLocaleLoaded(instance, pluginName, pluginPath, targetLanguage);
}
await instance.changeLanguage(targetLanguage);
})();
}, [mainI18n?.language, instance, pluginName]);

// Translation function
const t = (key: string, options?: TOptions) => {
Expand Down Expand Up @@ -292,12 +343,21 @@ export function getPluginTranslationsInfo(): PluginI18nInfo[] {
}

/**
* Change language for all plugin instances
* Change language for all plugin instances, loading the target locale on demand
* so locales are only fetched when actually used rather than all up front (#4854).
*/
export function changePluginLanguage(language: string) {
Object.values(pluginI18nInstances).forEach(instance => {
instance.changeLanguage(language);
});
export async function changePluginLanguage(language: string): Promise<void> {
await Promise.all(
Object.keys(pluginI18nInstances).map(async pluginName => {
const instance = pluginI18nInstances[pluginName];
const pluginPath = pluginPaths[pluginName];
const supportedLocales = pluginSupportedLocales[pluginName];
if (pluginPath && supportedLocales?.includes(language)) {
await ensurePluginLocaleLoaded(instance, pluginName, pluginPath, language);
}
await instance.changeLanguage(language);
})
);
}
Comment on lines +349 to 361

/**
Expand Down
Loading